Skip to content

Commit b1ffb5c

Browse files
committed
feat(ai): 全局 AI 对话面板(丙案)与补充就地采纳断点(REQ-274)
1 parent 16c5b31 commit b1ffb5c

4 files changed

Lines changed: 407 additions & 3 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import SettingsPage from "./pages/SettingsPage";
2424
import KnowledgePage from "./pages/KnowledgePage";
2525
// v0.18.0:学习目标页(意图层——独立 Tab,零叙事元素)
2626
import GoalsPage from "./pages/GoalsPage";
27+
// REQ-274(v0.19.4):全局 AI 对话面板(丙案——按需唤起 + 内容保活)
28+
import AiConversationDock from "./components/AiConversationDock";
2729
import AppErrorBoundary from "./components/AppErrorBoundary";
2830
import CaptureFloatPanel from "./components/CaptureFloatPanel";
2931
import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
@@ -84,6 +86,9 @@ function App() {
8486
// 任务进入对话页(会话页精修启动 → AI 对话页选中该任务)
8587
const [focusRefineTaskId, setFocusRefineTaskId] = useState<number | null>(null);
8688
const [focusChatTaskId, setFocusChatTaskId] = useState<number | null>(null);
89+
// REQ-274:对话面板开合 + 「在对话页继续」直达会话(消费后清空)
90+
const [dockOpen, setDockOpen] = useState(false);
91+
const [focusChatId, setFocusChatId] = useState<number | null>(null);
8792
// 全局采集状态(ADR-007:与页面解耦,徽标常驻导航栏)
8893
const [capturing, setCapturing] = useState(false);
8994
const [recovering, setRecovering] = useState(false);
@@ -192,6 +197,19 @@ function App() {
192197
};
193198
}, []);
194199

200+
// REQ-274:对话面板本地快捷键 Ctrl+Shift+A(v1 本地 window 级——全局
201+
// 快捷键随插件能力后续升级)
202+
useEffect(() => {
203+
const onKey = (e: KeyboardEvent) => {
204+
if (e.ctrlKey && e.shiftKey && (e.key === "A" || e.key === "a")) {
205+
e.preventDefault();
206+
setDockOpen((v) => !v);
207+
}
208+
};
209+
window.addEventListener("keydown", onKey);
210+
return () => window.removeEventListener("keydown", onKey);
211+
}, []);
212+
195213
return (
196214
// v0.8.0 真机白屏防御(2026-08-21):全局错误边界——渲染异常显示错误
197215
// 卡片而非整树卸载白屏;console 打印调用栈便于定位(AppErrorBoundary)
@@ -231,6 +249,21 @@ function App() {
231249
{item.label}
232250
</button>
233251
))}
252+
{/* REQ-274:全局 AI 对话面板入口(按需唤起;Ctrl+Shift+A 等效) */}
253+
<button
254+
data-testid="dock-toggle"
255+
onClick={() => setDockOpen((v) => !v)}
256+
title="AI 任务对话面板(Ctrl+Shift+A)"
257+
style={{
258+
marginLeft: "auto",
259+
padding: "6px 12px", fontSize: 12, cursor: "pointer",
260+
border: dockOpen ? "1px solid #4f46e5" : "1px solid #d1d5db",
261+
background: dockOpen ? "#eef2ff" : "#fff",
262+
color: dockOpen ? "#3730a3" : "#374151", borderRadius: 8,
263+
}}
264+
>
265+
🤖 对话面板
266+
</button>
234267
{/* 全局采集徽标(ADR-007):切页/最小化后仍可见采集状态;2026-08 A1 暂停态 */}
235268
{capturing && (
236269
<span
@@ -329,6 +362,9 @@ function App() {
329362
// v0.16.1:任务进入对话页(会话页精修启动自动跳转);任务视图 → 工作台深链
330363
focusTaskId={focusChatTaskId}
331364
onFocusTaskConsumed={() => setFocusChatTaskId(null)}
365+
// REQ-274:对话面板「在对话页继续」直达会话(消费后清空)
366+
focusChatId={focusChatId}
367+
onFocusChatConsumed={() => setFocusChatId(null)}
332368
onOpenRefineWorkbench={(sessionId, taskId) => {
333369
setFocusSessionId(sessionId);
334370
setFocusRefineTaskId(taskId);
@@ -355,6 +391,20 @@ function App() {
355391
<SettingsPage active={page === "settings"} />
356392
</div>
357393
</main>
394+
{/* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活) */}
395+
<AiConversationDock
396+
open={dockOpen}
397+
onClose={() => setDockOpen(false)}
398+
onOpenChat={(chatId) => { setFocusChatId(chatId); setPage("chat"); }}
399+
onOpenTaskInChat={(taskId) => { setFocusChatTaskId(taskId); setPage("chat"); }}
400+
onOpenSessions={(id) => { setFocusSessionId(id); setPage("sessions"); }}
401+
onOpenNote={(id) => openNotePlain(id)}
402+
onOpenRefineWorkbench={(sessionId, taskId) => {
403+
setFocusSessionId(sessionId);
404+
setFocusRefineTaskId(taskId);
405+
setPage("sessions");
406+
}}
407+
/>
358408
</div>
359409
</AppErrorBoundary>
360410
);
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)