|
| 1 | +/** |
| 2 | + * AiConversationDock — 全局 AI 对话面板(REQ-274 v0.19.4,丙案 v1)。 |
| 3 | + * |
| 4 | + * @ai-context: 应用级右侧停靠「AI 任务对话」跟随工作区——按需唤起 + 内容保活 |
| 5 | + * (常驻挂载,open 仅切 display——选中态/已加载列表不销毁,关闭后 |
| 6 | + * 任务在后台照跑,完成经 App 既有 ai:task-update toast 通知)。 |
| 7 | + * @ai-context: 同源承诺:本面板不建第二套会话——数据全部来自既有命令 |
| 8 | + * (chat_list_sessions/chat_list_messages/ai_task_history/ |
| 9 | + * ai_task_conversation),线程内容在「💬 AI 对话」页天然可见可查; |
| 10 | + * 会话视图 v1 只读 + 「在对话页继续」跳转(规避双实例流控冲突), |
| 11 | + * 任务视图复用 TaskConversationView(含采纳/跳转/工作台深链)。 |
| 12 | + * @ai-context: 唤起:导航栏 🤖 按钮 / Ctrl+Shift+A(本地 window 快捷键); |
| 13 | + * 段切换:💬 对话会话 / 🤖 AI 任务(与 ChatSidebar 口径一致)。 |
| 14 | + */ |
| 15 | +import { useCallback, useEffect, useRef, useState } from "react"; |
| 16 | +import { invoke } from "@tauri-apps/api/core"; |
| 17 | +import type { AiTaskRecord, AiTurn, ChatMessage, ChatSession } from "../types"; |
| 18 | +import TaskConversationView from "./TaskConversationView"; |
| 19 | +import { refLabel } from "../utils/entityLabel"; |
| 20 | + |
| 21 | +interface SessionRow { id: number; title: string } |
| 22 | + |
| 23 | +interface Props { |
| 24 | + open: boolean; |
| 25 | + onClose: () => void; |
| 26 | + /** 跳转对话页并选中会话(App 复用既有 focus 机制) */ |
| 27 | + onOpenChat: (chatId: number) => void; |
| 28 | + /** 跳转对话页任务段 */ |
| 29 | + onOpenTaskInChat: (taskId: number) => void; |
| 30 | + onOpenSessions: (sessionId: number) => void; |
| 31 | + onOpenNote: (noteId: number) => void; |
| 32 | + onOpenRefineWorkbench: (sessionId: number, taskId: number) => void; |
| 33 | +} |
| 34 | + |
| 35 | +const PANEL_W = 560; |
| 36 | + |
| 37 | +const itemBase: React.CSSProperties = { |
| 38 | + display: "flex", alignItems: "center", gap: 8, width: "100%", boxSizing: "border-box", |
| 39 | + padding: "7px 10px", cursor: "pointer", fontSize: 12.5, textAlign: "left", |
| 40 | + background: "none", border: "none", borderRadius: 8, color: "#374151", |
| 41 | +}; |
| 42 | + |
| 43 | +const OP_LABEL: Record<string, string> = { refine: "✨ 精修", enrich: "📚 补充" }; |
| 44 | + |
| 45 | +export default function AiConversationDock({ |
| 46 | + open, onClose, onOpenChat, onOpenTaskInChat, onOpenSessions, onOpenNote, onOpenRefineWorkbench, |
| 47 | +}: Props) { |
| 48 | + const [tab, setTab] = useState<"chat" | "task">("task"); |
| 49 | + const [chatSessions, setChatSessions] = useState<ChatSession[]>([]); |
| 50 | + const [tasks, setTasks] = useState<AiTaskRecord[]>([]); |
| 51 | + const [sessionTitles, setSessionTitles] = useState<Map<number, string>>(new Map()); |
| 52 | + const [noteTitles, setNoteTitles] = useState<Map<number, string>>(new Map()); |
| 53 | + const [activeChatId, setActiveChatId] = useState<number | null>(null); |
| 54 | + const [messages, setMessages] = useState<ChatMessage[]>([]); |
| 55 | + const [activeTaskId, setActiveTaskId] = useState<number | null>(null); |
| 56 | + const [taskDetail, setTaskDetail] = useState<{ task: AiTaskRecord; turns: AiTurn[] } | null>(null); |
| 57 | + const [notice, setNotice] = useState(""); |
| 58 | + const loadedOnce = useRef(false); |
| 59 | + |
| 60 | + const reloadTasks = useCallback(async () => { |
| 61 | + const refine = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "refine", limit: 30 }).catch(() => [] as AiTaskRecord[]); |
| 62 | + const enrich = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "enrich", limit: 30 }).catch(() => [] as AiTaskRecord[]); |
| 63 | + setTasks([...refine, ...enrich].sort((a, b) => b.createdAt - a.createdAt).slice(0, 40)); |
| 64 | + }, []); |
| 65 | + |
| 66 | + /** 会话/标题静态数据(与 ChatPage 同源同口径——打开面板时拉一次) */ |
| 67 | + const loadStatic = useCallback(async () => { |
| 68 | + const [sess, rows, notes] = await Promise.all([ |
| 69 | + invoke<ChatSession[]>("chat_list_sessions").catch(() => [] as ChatSession[]), |
| 70 | + invoke<SessionRow[]>("list_sessions", { limit: 500 }).catch(() => [] as SessionRow[]), |
| 71 | + invoke<{ id: number; title: string }[]>("search_notes", { keyword: "", tag: null as string | null }).catch(() => [] as { id: number; title: string }[]), |
| 72 | + ]); |
| 73 | + setChatSessions(sess); |
| 74 | + setSessionTitles(new Map(rows.map((s) => [s.id, s.title]))); |
| 75 | + setNoteTitles(new Map(notes.map((n) => [n.id, n.title]))); |
| 76 | + }, []); |
| 77 | + |
| 78 | + useEffect(() => { |
| 79 | + if (loadedOnce.current) return; |
| 80 | + loadedOnce.current = true; |
| 81 | + void loadStatic(); |
| 82 | + void reloadTasks(); |
| 83 | + }, [loadStatic, reloadTasks]); |
| 84 | + |
| 85 | + // open 变 true:刷新静态/任务(外部变化及时可见——防抖由调用频率天然承担) |
| 86 | + useEffect(() => { |
| 87 | + if (open) { |
| 88 | + void loadStatic(); |
| 89 | + void reloadTasks(); |
| 90 | + } |
| 91 | + }, [open, loadStatic, reloadTasks]); |
| 92 | + |
| 93 | + const selectTask = useCallback(async (taskId: number) => { |
| 94 | + setActiveTaskId(taskId); |
| 95 | + setActiveChatId(null); |
| 96 | + setNotice(""); |
| 97 | + try { |
| 98 | + const [task, turns] = await invoke<[AiTaskRecord, AiTurn[]]>("ai_task_conversation", { taskId }); |
| 99 | + setTaskDetail({ task, turns }); |
| 100 | + } catch (e) { |
| 101 | + setTaskDetail(null); |
| 102 | + setNotice(String(e)); |
| 103 | + } |
| 104 | + }, []); |
| 105 | + |
| 106 | + const selectChat = useCallback(async (chatId: number) => { |
| 107 | + setActiveChatId(chatId); |
| 108 | + setActiveTaskId(null); |
| 109 | + setTaskDetail(null); |
| 110 | + setNotice(""); |
| 111 | + setMessages(await invoke<ChatMessage[]>("chat_list_messages", { sessionId: chatId }).catch(() => [] as ChatMessage[])); |
| 112 | + }, []); |
| 113 | + |
| 114 | + const taskRefTitle = useCallback((t: AiTaskRecord): string => |
| 115 | + t.opType === "refine" |
| 116 | + ? refLabel("session", sessionTitles.get(t.refId)) |
| 117 | + : refLabel("note", noteTitles.get(t.refId)), |
| 118 | + [sessionTitles, noteTitles]); |
| 119 | + |
| 120 | + /** 列表段(每行数据复用 ChatSidebar 的形态——标题/模型/状态/时间) */ |
| 121 | + const renderList = () => { |
| 122 | + if (tab === "task") { |
| 123 | + return tasks.length === 0 ? ( |
| 124 | + <div style={{ padding: 12, fontSize: 12, color: "#9ca3af" }}>暂无精修/补充任务</div> |
| 125 | + ) : ( |
| 126 | + tasks.map((t) => { |
| 127 | + const done = t.state === "succeeded" || t.state === "failed"; |
| 128 | + const color = t.state === "succeeded" ? "#047857" : t.state === "failed" ? "#b91c1c" : "#b45309"; |
| 129 | + return ( |
| 130 | + <button |
| 131 | + key={t.taskId} |
| 132 | + onClick={() => void selectTask(t.taskId)} |
| 133 | + style={{ |
| 134 | + ...itemBase, |
| 135 | + background: activeTaskId === t.taskId ? "#f0fdfa" : undefined, |
| 136 | + border: activeTaskId === t.taskId ? "1px solid #99f6e4" : "1px solid transparent", |
| 137 | + }} |
| 138 | + > |
| 139 | + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> |
| 140 | + {OP_LABEL[t.opType] ?? t.opType} {taskRefTitle(t)} |
| 141 | + </span> |
| 142 | + <span style={{ fontSize: 10, fontWeight: 600, color }}>{done ? (t.state === "succeeded" ? "✓" : "✗") : "…"}</span> |
| 143 | + <span style={{ fontSize: 10, color: "#9ca3af" }}> |
| 144 | + {new Date(t.createdAt * 1000).toLocaleTimeString()} |
| 145 | + </span> |
| 146 | + </button> |
| 147 | + ); |
| 148 | + }) |
| 149 | + ); |
| 150 | + } |
| 151 | + return chatSessions.length === 0 ? ( |
| 152 | + <div style={{ padding: 12, fontSize: 12, color: "#9ca3af" }}>暂无对话会话——到「💬 AI 对话」页新建</div> |
| 153 | + ) : ( |
| 154 | + chatSessions.map((s) => ( |
| 155 | + <button |
| 156 | + key={s.id} |
| 157 | + onClick={() => void selectChat(s.id)} |
| 158 | + style={{ |
| 159 | + ...itemBase, |
| 160 | + background: activeChatId === s.id ? "#f0fdfa" : undefined, |
| 161 | + border: activeChatId === s.id ? "1px solid #99f6e4" : "1px solid transparent", |
| 162 | + }} |
| 163 | + > |
| 164 | + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.title}</span> |
| 165 | + {s.model && <span style={{ fontSize: 10, color: "#9ca3af" }}>{s.model.split("/").pop()}</span>} |
| 166 | + </button> |
| 167 | + )) |
| 168 | + ); |
| 169 | + }; |
| 170 | + |
| 171 | + /** 会话详情 v1 只读消息(同源——继续对话请到 AI 对话页) */ |
| 172 | + const renderChatDetail = () => { |
| 173 | + if (messages.length === 0) { |
| 174 | + return <div style={{ padding: 14, fontSize: 12, color: "#9ca3af" }}>该会话暂无消息</div>; |
| 175 | + } |
| 176 | + return messages.map((m) => ( |
| 177 | + <div key={m.id} style={{ marginBottom: 8 }}> |
| 178 | + <div style={{ fontSize: 10, color: "#9ca3af", marginBottom: 2 }}> |
| 179 | + {m.role === "user" ? "🧑 你" : "🤖 AI"}{m.model ? ` · ${m.model}` : ""} |
| 180 | + </div> |
| 181 | + <div style={{ |
| 182 | + whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 12.5, lineHeight: 1.6, |
| 183 | + background: m.role === "user" ? "#eef2ff" : "#f9fafb", |
| 184 | + border: "1px solid #e5e7eb", borderRadius: 8, padding: "6px 10px", color: "#1f2937", |
| 185 | + }}>{m.content}</div> |
| 186 | + </div> |
| 187 | + )); |
| 188 | + }; |
| 189 | + |
| 190 | + /** 顶部:并发起按钮 + 段切换 */ |
| 191 | + const renderHeader = () => ( |
| 192 | + <div style={{ |
| 193 | + display: "flex", alignItems: "center", gap: 8, padding: "10px 12px", |
| 194 | + borderBottom: "1px solid #e5e7eb", background: "#fff", flexShrink: 0, |
| 195 | + }}> |
| 196 | + <span style={{ fontWeight: 700, fontSize: 13.5 }}>🤖 AI 任务对话</span> |
| 197 | + <button |
| 198 | + data-testid="dock-tab-task" |
| 199 | + onClick={() => setTab("task")} |
| 200 | + style={{ |
| 201 | + marginLeft: 8, fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 999, |
| 202 | + border: tab === "task" ? "1px solid #4f46e5" : "1px solid #d1d5db", |
| 203 | + background: tab === "task" ? "#eef2ff" : "#fff", color: tab === "task" ? "#3730a3" : "#374151", |
| 204 | + }} |
| 205 | + >🤖 AI 任务</button> |
| 206 | + <button |
| 207 | + data-testid="dock-tab-chat" |
| 208 | + onClick={() => setTab("chat")} |
| 209 | + style={{ |
| 210 | + fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 999, |
| 211 | + border: tab === "chat" ? "1px solid #4f46e5" : "1px solid #d1d5db", |
| 212 | + background: tab === "chat" ? "#eef2ff" : "#fff", color: tab === "chat" ? "#3730a3" : "#374151", |
| 213 | + }} |
| 214 | + >💬 对话</button> |
| 215 | + <span style={{ flex: 1 }} /> |
| 216 | + <button |
| 217 | + data-testid="dock-close" |
| 218 | + onClick={onClose} |
| 219 | + style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 14, color: "#6b7280" }} |
| 220 | + >✕</button> |
| 221 | + </div> |
| 222 | + ); |
| 223 | + |
| 224 | + return ( |
| 225 | + <div |
| 226 | + data-testid="ai-dock" |
| 227 | + style={{ |
| 228 | + position: "fixed", top: 56, right: 0, bottom: 0, width: PANEL_W, zIndex: 900, |
| 229 | + background: "#fff", borderLeft: "1px solid #e5e7eb", boxShadow: "-8px 0 24px rgba(0,0,0,0.08)", |
| 230 | + display: open ? "flex" : "none", flexDirection: "column", overflow: "hidden", |
| 231 | + fontSize: 12.5, color: "#1f2937", |
| 232 | + }} |
| 233 | + > |
| 234 | + {renderHeader()} |
| 235 | + {/* 列表区(选中项高亮;点击切换) */} |
| 236 | + <div style={{ flex: 1, minHeight: 0, display: activeChatId == null && activeTaskId == null ? "flex" : "none", flexDirection: "column", overflowY: "auto", padding: 6, borderBottom: "1px solid #f3f4f6" }}> |
| 237 | + {renderList()} |
| 238 | + </div> |
| 239 | + {/* 详情区 */} |
| 240 | + <div style={{ flex: 1, minHeight: 0, display: activeChatId != null || activeTaskId != null ? "flex" : "none", flexDirection: "column", overflow: "hidden" }}> |
| 241 | + {activeTaskId != null && taskDetail && ( |
| 242 | + <> |
| 243 | + <div style={{ flexShrink: 0, display: "flex", gap: 6, padding: "4px 10px", borderBottom: "1px solid #f3f4f6", fontSize: 11 }}> |
| 244 | + <button |
| 245 | + onClick={() => { setActiveTaskId(null); setTaskDetail(null); }} |
| 246 | + style={{ border: "none", background: "none", cursor: "pointer", color: "#0f766e", padding: 0 }} |
| 247 | + >← 任务列表</button> |
| 248 | + <span style={{ flex: 1 }} /> |
| 249 | + <button |
| 250 | + title="在 AI 对话页打开同源任务(可查轨迹/追问)" |
| 251 | + onClick={() => onOpenTaskInChat(taskDetail.task.taskId)} |
| 252 | + style={{ border: "none", background: "none", cursor: "pointer", color: "#0d9488", padding: 0 }} |
| 253 | + >💬 在对话页打开 →</button> |
| 254 | + </div> |
| 255 | + <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}> |
| 256 | + <TaskConversationView |
| 257 | + task={taskDetail.task} |
| 258 | + turns={taskDetail.turns} |
| 259 | + refTitle={taskRefTitle(taskDetail.task)} |
| 260 | + onOpenSession={onOpenSessions} |
| 261 | + onOpenNote={onOpenNote} |
| 262 | + onRetry={() => { |
| 263 | + setNotice("请在「💬 AI 对话」页的任务视图重试(同源线程,避免双流)"); |
| 264 | + void reloadTasks(); |
| 265 | + }} |
| 266 | + busy={false} |
| 267 | + // 笔记级任务 → 查看笔记;会话级 → 工作台深链(同 ChatPage 口径) |
| 268 | + onOpenWorkbench={(t) => (t.targetKind === "note" ? onOpenNote(t.refId) : onOpenRefineWorkbench(t.refId, t.taskId))} |
| 269 | + onTaskChanged={() => void reloadTasks()} |
| 270 | + /> |
| 271 | + </div> |
| 272 | + </> |
| 273 | + )} |
| 274 | + {activeChatId != null && ( |
| 275 | + <> |
| 276 | + <div style={{ flexShrink: 0, display: "flex", gap: 6, padding: "4px 10px", borderBottom: "1px solid #f3f4f6", fontSize: 11 }}> |
| 277 | + <button |
| 278 | + onClick={() => { setActiveChatId(null); setMessages([]); }} |
| 279 | + style={{ border: "none", background: "none", cursor: "pointer", color: "#0f766e", padding: 0 }} |
| 280 | + >← 会话列表</button> |
| 281 | + <span style={{ flex: 1 }} /> |
| 282 | + <button |
| 283 | + title="到 AI 对话页继续对话(本面板会话视图 v1 只读)" |
| 284 | + onClick={() => onOpenChat(activeChatId)} |
| 285 | + style={{ border: "none", background: "none", cursor: "pointer", color: "#0d9488", padding: 0 }} |
| 286 | + >💬 在对话页继续 →</button> |
| 287 | + </div> |
| 288 | + <div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 12 }}>{renderChatDetail()}</div> |
| 289 | + </> |
| 290 | + )} |
| 291 | + </div> |
| 292 | + {notice && ( |
| 293 | + <div style={{ |
| 294 | + flexShrink: 0, padding: "6px 12px", fontSize: 11.5, borderTop: "1px solid #f3f4f6", |
| 295 | + color: notice.startsWith("✅") ? "#047857" : "#b45309", background: notice.startsWith("✅") ? "#f0fdfa" : "#fffbeb", |
| 296 | + }}>{notice}</div> |
| 297 | + )} |
| 298 | + </div> |
| 299 | + ); |
| 300 | +} |
0 commit comments