Skip to content

Commit a27954e

Browse files
committed
feat(ai): AI 任务对话化与精修工作台深链——聊天发起(按钮+/命令)+ 线程任务卡 + 追问预填 + 会话页精修启动自动跳转 + 任务视图→工作台(REQ-241/242)
1 parent 5d08090 commit a27954e

13 files changed

Lines changed: 749 additions & 12 deletions

‎app/src/App.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import KnowledgePage from "./pages/KnowledgePage";
2525
import AppErrorBoundary from "./components/AppErrorBoundary";
2626
import CaptureFloatPanel from "./components/CaptureFloatPanel";
2727
import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
28+
// v0.16.1:浏览器痕迹去除(原生右键菜单抑制 + 文本输入应用内右键小菜单)
29+
import BrowserChrome from "./components/BrowserChrome";
2830
import type { AiTaskState } from "./types";
2931

3032
type Page = "classroom" | "sessions" | "notes" | "chat" | "knowledge" | "settings";
@@ -63,6 +65,10 @@ function App() {
6365
const [focusSystemId, setFocusSystemId] = useState<number | null>(null);
6466
// v0.14 C2:图谱组节点 → 笔记页过滤该组(同 focusNoteId 模式)
6567
const [focusGroupId, setFocusGroupId] = useState<number | null>(null);
68+
// v0.16.1:工作台深链(对话页任务视图 → 会话页自动展开精修工作台)与
69+
// 任务进入对话页(会话页精修启动 → AI 对话页选中该任务)
70+
const [focusRefineTaskId, setFocusRefineTaskId] = useState<number | null>(null);
71+
const [focusChatTaskId, setFocusChatTaskId] = useState<number | null>(null);
6672
// 全局采集状态(ADR-007:与页面解耦,徽标常驻导航栏)
6773
const [capturing, setCapturing] = useState(false);
6874
const [recovering, setRecovering] = useState(false);
@@ -176,6 +182,8 @@ function App() {
176182
// 卡片而非整树卸载白屏;console 打印调用栈便于定位(AppErrorBoundary)
177183
<AppErrorBoundary>
178184
<div style={{ height: "100vh", display: "flex", flexDirection: "column", fontFamily: "system-ui, sans-serif" }}>
185+
{/* v0.16.1:浏览器痕迹去除——原生右键菜单抑制 + 文本输入应用内右键小菜单 */}
186+
<BrowserChrome />
179187
{/* 顶部导航 */}
180188
<nav
181189
style={{
@@ -266,6 +274,13 @@ function App() {
266274
{/* v0.7.1:active 驱动列表刷新(display:none 挂载不刷新根治)+ 查看笔记跨页直达 */}
267275
<SessionsPage
268276
focusSessionId={focusSessionId}
277+
// v0.16.1:工作台深链 / 精修启动 → AI 对话页(focus 消费后即清空)
278+
focusRefineTaskId={focusRefineTaskId}
279+
onFocusRefineTaskConsumed={() => setFocusRefineTaskId(null)}
280+
onRefineTaskStarted={(_sessionId, taskId) => {
281+
setFocusChatTaskId(taskId);
282+
setPage("chat");
283+
}}
269284
active={page === "sessions"}
270285
onOpenNote={(id) => {
271286
setFocusNoteId(id);
@@ -294,6 +309,14 @@ function App() {
294309
onOpenSessions={(id) => { setFocusSessionId(id); setPage("sessions"); }}
295310
onOpenNote={(id) => { setFocusNoteId(id); setPage("notes"); }}
296311
onOpenSettings={() => setPage("settings")}
312+
// v0.16.1:任务进入对话页(会话页精修启动自动跳转);任务视图 → 工作台深链
313+
focusTaskId={focusChatTaskId}
314+
onFocusTaskConsumed={() => setFocusChatTaskId(null)}
315+
onOpenRefineWorkbench={(sessionId, taskId) => {
316+
setFocusSessionId(sessionId);
317+
setFocusRefineTaskId(taskId);
318+
setPage("sessions");
319+
}}
297320
/>
298321
</div>
299322
<div style={{ flex: 1, display: page === "knowledge" ? "block" : "none", overflow: "hidden" }}>

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @ai-context: 降级可见:任务失败按原因四类展示引导(未授权→设置密钥/授权、
99
* 网络→重试、余额→充值、配额→明日再试),本地规则版保留。
1010
*/
11-
import { useCallback, useEffect, useState } from "react";
11+
import { useCallback, useEffect, useRef, useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
1313
// M8:轮询/事件双通道/卡住检测抽入共用 hook(与 EnrichPanel 同源)
1414
import { useAiTaskPolling } from "../hooks/useAiTaskPolling";
@@ -25,7 +25,18 @@ import RefineWorkbench from "./RefineWorkbench";
2525

2626
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
2727

28-
export default function AiRefineCard({ sessionId, onApplied }: { sessionId: number; onApplied?: (id: number) => void }) {
28+
export default function AiRefineCard({
29+
sessionId, onApplied, autoTaskId, onTaskStarted, onAutoTaskMissing,
30+
}: {
31+
sessionId: number;
32+
onApplied?: (id: number) => void;
33+
/** v0.16.1:深链任务 id——挂载即取回结果并直接展开工作台(对话页任务视图跳转) */
34+
autoTaskId?: number | null;
35+
/** v0.16.1:任务启动成功回调(会话页精修 → 自动跳 AI 对话页) */
36+
onTaskStarted?: (sessionId: number, taskId: number) => void;
37+
/** v0.16.1:深链任务取回失败(任务非成功态/已清理——父层提示并清焦点) */
38+
onAutoTaskMissing?: () => void;
39+
}) {
2940
const [settings, setSettings] = useState<AiSettingsView | null>(null);
3041
const [phase, setPhase] = useState<"idle" | "consent" | "confirm" | "running" | "done" | "failed">("idle");
3142
const [estimate, setEstimate] = useState<RefineEstimateView | null>(null);
@@ -37,11 +48,31 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
3748
const [msg, setMsg] = useState("");
3849
const [, setTaskId] = useState<number | null>(null);
3950
const [showWorkbench, setShowWorkbench] = useState(false);
51+
// v0.16.1 审查修复:工作台「重新生成」不经用户启动路径——旁路导航跳转
52+
// (否则每次 regenerate 都会 onTaskStarted → App 把你从会话页拽到 AI 对话页)
53+
const skipNavigateRef = useRef(false);
4054

4155
useEffect(() => {
4256
void invoke<AiSettingsView>("ai_get_settings").then(setSettings).catch(() => undefined);
4357
}, []);
4458

59+
// v0.16.1:工作台深链——带 autoTaskId 挂载 → 取回结果直接展开工作台(免重跑流程)
60+
useEffect(() => {
61+
if (autoTaskId == null) return;
62+
void (async () => {
63+
try {
64+
const r = await invoke<AiRefineResult>("ai_refine_result", { taskId: autoTaskId });
65+
taskIdRef.current = autoTaskId;
66+
setResult(r);
67+
setPhase("done");
68+
setShowWorkbench(true);
69+
} catch {
70+
onAutoTaskMissing?.();
71+
}
72+
})();
73+
// eslint-disable-next-line react-hooks/exhaustive-deps
74+
}, [autoTaskId]);
75+
4576
// M8:任务状态派发(纯业务)——轮询/事件/卡住检测/卸载清理均由
4677
// useAiTaskPolling 承担;taskId 以参数传入(原实现靠 ref 镜像防闭包过期,
4778
// 现由 hook 内部 ref 统一保障)
@@ -128,6 +159,9 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
128159
});
129160
if (handle) {
130161
taskIdRef.current = handle.taskId;
162+
// v0.16.1:会话页精修启动 → 自动跳 AI 对话页(任务卡入聊天线程/可追问);
163+
// 工作台重新生成路径经 skipNavigateRef 旁路(审查修复:不跳转)
164+
if (!skipNavigateRef.current) onTaskStarted?.(sessionId, handle.taskId);
131165
setTaskId(handle.taskId);
132166
void handleState(handle.state, handle.taskId);
133167
startPolling(handle.taskId);
@@ -255,8 +289,12 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
255289
// 任务中心重复采纳产生重复笔记;原实现恒传 null 丢失该保障)
256290
taskId={taskIdRef.current}
257291
// 重新生成走父级任务管线(running 态 + 轮询/事件 + 卡住检测)——
258-
// 原工作台直连 ai_refine_start 导致卡片状态残留(旧结果+无进度)
259-
onRegenerate={async () => { await start(); }}
292+
// 原工作台直连 ai_refine_start 导致卡片状态残留(旧结果+无进度);
293+
// 审查修复:regenerate 旁路 onTaskStarted 导航(不离开当前页)
294+
onRegenerate={async () => {
295+
skipNavigateRef.current = true;
296+
try { await start(); } finally { skipNavigateRef.current = false; }
297+
}}
260298
onClose={() => setShowWorkbench(false)}
261299
onApplied={(id) => { reset(); onApplied?.(id); }}
262300
/>

‎app/src/components/NotePreviewView.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,15 @@ function renderMarkdown(md: string, imageBaseUrl: string, dataDir: string): stri
7373
.join("");
7474
}
7575

76-
export default function NotePreviewView({ sessionId }: { sessionId: number }) {
76+
export default function NotePreviewView({
77+
sessionId, autoTaskId, onTaskStarted,
78+
}: {
79+
sessionId: number;
80+
/** v0.16.1:工作台深链任务 id(透传 AiRefineCard——对话页任务视图跳转) */
81+
autoTaskId?: number | null;
82+
/** v0.16.1:精修任务启动回调(会话页 → 跳 AI 对话页) */
83+
onTaskStarted?: (sessionId: number, taskId: number) => void;
84+
}) {
7785
const [preview, setPreview] = useState<NoteFilterResult | null>(null);
7886
const [status, setStatus] = useState("");
7987
const [showFiltered, setShowFiltered] = useState(false);
@@ -231,7 +239,13 @@ export default function NotePreviewView({ sessionId }: { sessionId: number }) {
231239
</div>
232240
{/* v0.8.0 M2(REQ-141/145):AI 精修卡——预估确认/异步任务/diff 预览/采纳落库;
233241
采纳成功 → onApplied 联动切换预览为精修版(2026-08-21 体验修复) */}
234-
<AiRefineCard sessionId={sessionId} onApplied={(id) => void handleRefineApplied(id)} />
242+
<AiRefineCard
243+
sessionId={sessionId}
244+
onApplied={(id) => void handleRefineApplied(id)}
245+
// v0.16.1:深链/跳转透传(对话页任务视图 → 工作台;会话页精修启动 → 对话页)
246+
autoTaskId={autoTaskId}
247+
onTaskStarted={onTaskStarted}
248+
/>
235249
{status && <p style={{ fontSize: 12, color: "#2563eb", marginBottom: 6 }}>{status}</p>}
236250
{aiMeta && (
237251
<p style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,15 @@ interface Props {
5555
onRemove: (id: number) => void;
5656
/** 重新拉详情(v0.11.5:session:refined 事件驱动屏卡结构回填) */
5757
onRefreshDetail: (id: number) => void;
58+
/** v0.16.1:工作台深链任务 id(对话页任务视图跳转——自动切预览视图并展开工作台) */
59+
autoRefineTaskId?: number | null;
60+
/** v0.16.1:autoTaskId 消费完成回调(App 清空 focus——防陈旧值跨导航复触发) */
61+
onAutoTaskConsumed?: () => void;
62+
/** v0.16.1:精修任务启动回调(→ AI 对话页) */
63+
onRefineTaskStarted?: (sessionId: number, taskId: number) => void;
5864
}
5965

60-
export default function SessionDetailPanel({ detail, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail }: Props) {
66+
export default function SessionDetailPanel({ detail, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail, autoRefineTaskId, onAutoTaskConsumed, onRefineTaskStarted }: Props) {
6167
// v0.5.0 M7(REQ-052)+ v0.6.0 M6(REQ-081):两视图(v0.11.5 产物视图下线)
6268
const [viewMode, setViewMode] = useState<"raw" | "preview">("raw");
6369
// M6(REQ-076):质量报告(可信度总览卡片)
@@ -146,6 +152,20 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
146152
.catch(() => undefined);
147153
}, [viewMode, sessionId]);
148154

155+
// v0.16.1:工作台深链——autoTaskId 到达即切预览视图(精修卡所在视图——原默认 raw)。
156+
// 面板内持有深链快照(deepTaskId)供 NotePreviewView/AiRefineCard 消费:App 侧
157+
// focus 清空发生在本面板 effect 之后,若直接透传 prop 会在卡片挂载前被置空
158+
// (竞态——工作台永不展开);快照 + 会话切换清除保证"只消费一次、不跨会话遗留"。
159+
const [deepTaskId, setDeepTaskId] = useState<number | null>(null);
160+
useEffect(() => { setDeepTaskId(null); }, [sessionId]);
161+
useEffect(() => {
162+
if (autoRefineTaskId != null) {
163+
setDeepTaskId(autoRefineTaskId);
164+
setViewMode("preview");
165+
onAutoTaskConsumed?.();
166+
}
167+
}, [autoRefineTaskId, onAutoTaskConsumed]);
168+
149169
// v0.11.5(spec 5️⃣):精修事件监听(自 ArtifactView 迁移)——
150170
// refining 进度 → refined 重新拉详情(屏卡 rendered 回填)→ skipped/failed 徽标
151171
useEffect(() => {
@@ -324,7 +344,11 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
324344
)}
325345

326346
{viewMode === "preview" ? (
327-
<NotePreviewView sessionId={sessionId} />
347+
<NotePreviewView
348+
sessionId={sessionId}
349+
autoTaskId={deepTaskId}
350+
onTaskStarted={onRefineTaskStarted}
351+
/>
328352
) : (
329353
<>
330354
{/* 转写时间轴(字幕为主,语音/融合弱化;段 id 锚点供大纲/搜索跳转) */}

‎app/src/components/TaskConversationView.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ interface Props {
2121
onRetry: (task: AiTaskRecord) => void;
2222
/** 重试中(进行中任务禁用按钮) */
2323
busy: boolean;
24+
/** v0.16.1:工作台深链(会话页自动展开精修工作台;缺省=不显示按钮) */
25+
onOpenWorkbench?: (task: AiTaskRecord) => void;
2426
}
2527

2628
function fmtDateTime(unix: number): string {
@@ -37,7 +39,7 @@ const STATE_LABEL: Record<string, { text: string; color: string }> = {
3739
partial_failed: { text: "部分成功", color: "#b45309" },
3840
};
3941

40-
export default function TaskConversationView({ task, turns, refTitle, onOpenSession, onOpenNote, onRetry, busy }: Props) {
42+
export default function TaskConversationView({ task, turns, refTitle, onOpenSession, onOpenNote, onRetry, busy, onOpenWorkbench }: Props) {
4143
const [openResult, setOpenResult] = useState(false);
4244
const [openTurns, setOpenTurns] = useState<Record<number, boolean>>({});
4345
const st = STATE_LABEL[task.state] ?? { text: task.state, color: "#6b7280" };
@@ -88,6 +90,17 @@ export default function TaskConversationView({ task, turns, refTitle, onOpenSess
8890
📌 来源笔记 #{task.refId} →
8991
</button>
9092
)}
93+
{/* v0.16.1:精修成功任务 → 会话页工作台深链(对比/采纳/重新生成一体) */}
94+
{isRefine && task.state === "succeeded" && onOpenWorkbench && (
95+
<button
96+
data-testid="open-workbench"
97+
onClick={() => onOpenWorkbench(task)}
98+
style={{ fontSize: 12, padding: "2px 10px", borderRadius: 6, border: "1px solid #c7d2fe", background: "#f5f3ff", color: "#4f46e5", cursor: "pointer" }}
99+
title="在会话页打开精修工作台(diff 对比 + 采纳/放弃/重新生成)"
100+
>
101+
🛠 打开精修工作台 →
102+
</button>
103+
)}
91104
{task.state === "failed" && (
92105
<button
93106
onClick={() => onRetry(task)}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* TaskLaunchDialog.test.tsx — 对话内发起任务对话框(v0.16.1)。AAA 模式。
4+
*
5+
* @ai-context: 覆盖三契约——① 未授权先同意(confirm + ai_set_authorized)再启动;
6+
* ② 授权与成本确认后 invoke ai_refine_start / ai_enrich_start(补充=九子项);
7+
* ③ 目标为空提示、取消不启动。
8+
*/
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
11+
12+
const { invokeMock, confirmMock } = vi.hoisted(() => ({ invokeMock: vi.fn(), confirmMock: vi.fn() }));
13+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
14+
vi.mock("@tauri-apps/plugin-dialog", () => ({ confirm: confirmMock }));
15+
16+
import TaskLaunchDialog from "./TaskLaunchDialog";
17+
18+
const sessions = [{ id: 12, title: "B站·化妆课 01" }];
19+
const notes = [{ id: 7, title: "化妆笔记" }];
20+
21+
beforeEach(() => {
22+
invokeMock.mockReset();
23+
confirmMock.mockReset();
24+
confirmMock.mockResolvedValue(true);
25+
invokeMock.mockImplementation(async (cmd: string) => {
26+
switch (cmd) {
27+
case "ai_get_settings": return { enabled: true, hasKey: true, authorized: true };
28+
case "ai_refine_estimate":
29+
case "ai_enrich_estimate": return { estimate: { estCostYuan: 0.012, estTokens: 1000 }, rememberCostChoice: false };
30+
case "ai_refine_start": return { taskId: 101, state: "Running" };
31+
case "ai_enrich_start": return { taskId: 202, state: "Running" };
32+
default: throw new Error(`unexpected ${cmd}`);
33+
}
34+
});
35+
});
36+
afterEach(() => cleanup());
37+
38+
describe("TaskLaunchDialog", () => {
39+
it("精修:选会话 → 成本确认 → ai_refine_start(sessionId, authorized) + onStarted", async () => {
40+
const onStarted = vi.fn();
41+
render(<TaskLaunchDialog kind="refine" sessions={sessions} notes={[]} onClose={vi.fn()} onStarted={onStarted} />);
42+
fireEvent.change(screen.getByTestId("task-launch-target"), { target: { value: "12" } });
43+
fireEvent.click(screen.getByTestId("task-launch-start"));
44+
await waitFor(() => {
45+
expect(invokeMock).toHaveBeenCalledWith("ai_refine_start", { sessionId: 12, authorized: true });
46+
});
47+
expect(onStarted).toHaveBeenCalledWith(101);
48+
});
49+
50+
it("补充:默认九子项起送(d1~d3+b1~b6)", async () => {
51+
const onStarted = vi.fn();
52+
render(<TaskLaunchDialog kind="enrich" sessions={[]} notes={notes} onClose={vi.fn()} onStarted={onStarted} />);
53+
fireEvent.change(screen.getByTestId("task-launch-target"), { target: { value: "7" } });
54+
fireEvent.click(screen.getByTestId("task-launch-start"));
55+
await waitFor(() => {
56+
expect(invokeMock).toHaveBeenCalledWith("ai_enrich_start", {
57+
noteId: 7,
58+
selectedKinds: ["d1", "d2", "d3", "b1", "b2", "b3", "b4", "b5", "b6"],
59+
authorized: true,
60+
});
61+
});
62+
expect(onStarted).toHaveBeenCalledWith(202);
63+
});
64+
65+
it("未授权 → 先同意(confirm + ai_set_authorized)再启动", async () => {
66+
invokeMock.mockImplementation(async (cmd: string) => {
67+
switch (cmd) {
68+
case "ai_get_settings": return { enabled: true, hasKey: true, authorized: false };
69+
case "ai_set_authorized": return null;
70+
case "ai_refine_estimate": return { estimate: { estCostYuan: 0.001 }, rememberCostChoice: false };
71+
case "ai_refine_start": return { taskId: 101, state: "Running" };
72+
default: throw new Error(`unexpected ${cmd}`);
73+
}
74+
});
75+
render(<TaskLaunchDialog kind="refine" sessions={sessions} notes={[]} onClose={vi.fn()} onStarted={vi.fn()} />);
76+
fireEvent.change(screen.getByTestId("task-launch-target"), { target: { value: "12" } });
77+
fireEvent.click(screen.getByTestId("task-launch-start"));
78+
await waitFor(() => {
79+
expect(confirmMock).toHaveBeenCalled();
80+
expect(invokeMock).toHaveBeenCalledWith("ai_set_authorized", { authorized: true });
81+
expect(invokeMock).toHaveBeenCalledWith("ai_refine_start", { sessionId: 12, authorized: true });
82+
});
83+
});
84+
85+
it("取消授权确认 → 不启动;无目标列表 → 提示", async () => {
86+
confirmMock.mockResolvedValue(false);
87+
render(<TaskLaunchDialog kind="refine" sessions={sessions} notes={[]} onClose={vi.fn()} onStarted={vi.fn()} />);
88+
fireEvent.change(screen.getByTestId("task-launch-target"), { target: { value: "12" } });
89+
fireEvent.click(screen.getByTestId("task-launch-start"));
90+
await waitFor(() => expect(invokeMock).not.toHaveBeenCalledWith("ai_refine_start", expect.anything()));
91+
});
92+
93+
it("initialTargetId(/refine 12 命令)预选目标——免手工选择", () => {
94+
render(<TaskLaunchDialog kind="refine" sessions={sessions} notes={[]} initialTargetId={12} onClose={vi.fn()} onStarted={vi.fn()} />);
95+
expect((screen.getByTestId("task-launch-target") as HTMLSelectElement).value).toBe("12");
96+
});
97+
});

0 commit comments

Comments
 (0)