Skip to content

Commit 6e72c5d

Browse files
committed
feat(ai): 任务中心前端——全局任务面板(历史/恢复/采纳复核)+ 完成通知 toast (F2-B3)
1 parent f06fd7f commit 6e72c5d

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ClassroomPage from "./pages/ClassroomPage";
1717
import NotesPage from "./pages/NotesPage";
1818
import SessionsPage from "./pages/SessionsPage";
1919
import AppErrorBoundary from "./components/AppErrorBoundary";
20+
import type { AiTaskState } from "./types";
2021

2122
type Page = "classroom" | "sessions" | "notes";
2223

@@ -37,12 +38,31 @@ function App() {
3738
const [recovering, setRecovering] = useState(false);
3839
// 2026-08 A1:会话暂停(live:paused/resumed 事件;徽标区分暂停态)
3940
const [paused, setPaused] = useState(false);
41+
// v0.8.0 F2(2026-08-21):AI 任务完成通知——全局监听 ai:task-update,
42+
// 跨页面可见(REQ-145"完成通知"落地;内联卡片之外的第二通道)
43+
const [aiToast, setAiToast] = useState<{ text: string; kind: "ok" | "err" } | null>(null);
4044

4145
useEffect(() => {
4246
let disposed = false;
4347
const unlisteners: (() => void)[] = [];
4448
// 监听器注册为异步(listen 返回 Promise<UnlistenFn>);组件卸载时统一解绑
4549
(async () => {
50+
// v0.8.0 F2:AI 任务完成通知(状态终态 → 全局 toast,3.5s 自动消失)
51+
unlisteners.push(
52+
await listen<[number, AiTaskState]>("ai:task-update", (e) => {
53+
if (disposed) return;
54+
const st = e.payload[1];
55+
if (st === "Succeeded") {
56+
setAiToast({ text: "✨ AI 任务已完成——可到内联卡片或「AI 任务中心」查看结果并采纳", kind: "ok" });
57+
} else if (typeof st === "object" && st !== null && "Failed" in st) {
58+
const reason = st.Failed.reason;
59+
const [kind, msg] = Object.entries(reason)[0] ?? ["other", "未知错误"];
60+
setAiToast({ text: `❌ AI 任务失败(${kind}):${msg}`, kind: "err" });
61+
}
62+
// toast 自动消失(重新计时——连续任务只显示最新)
63+
setTimeout(() => { if (!disposed) setAiToast(null); }, 3500);
64+
}),
65+
);
4666
// 采集主状态:recording=采集中;stopped/failed=结束(live_session 事件)
4767
unlisteners.push(
4868
await listen<string>("live:status", (e) => {
@@ -165,6 +185,27 @@ function App() {
165185
{paused ? "⏸ 已暂停" : recovering ? "⚠️ 采集恢复中" : "🎙 采集中"}
166186
</span>
167187
)}
188+
{/* v0.8.0 F2:AI 任务完成通知(全局 toast——跨页面可见) */}
189+
{aiToast && (
190+
<span
191+
style={{
192+
marginLeft: capturing ? 8 : "auto",
193+
fontSize: 12,
194+
fontWeight: 500,
195+
color: aiToast.kind === "ok" ? "#047857" : "#b91c1c",
196+
background: aiToast.kind === "ok" ? "#ecfdf5" : "#fef2f2",
197+
border: `1px solid ${aiToast.kind === "ok" ? "#a7f3d0" : "#fecaca"}`,
198+
borderRadius: 12,
199+
padding: "3px 10px",
200+
maxWidth: 420,
201+
overflow: "hidden",
202+
textOverflow: "ellipsis",
203+
whiteSpace: "nowrap",
204+
}}
205+
>
206+
{aiToast.text}
207+
</span>
208+
)}
168209
</nav>
169210

170211
{/* 页面区(TD-004:保留挂载 + display 切换——页面切换不重挂载,

‎app/src/components/AiTaskPanel.tsx‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* AiTaskPanel — AI 任务中心面板(F2 任务中心,2026-08-21)。
3+
*
4+
* @ai-context: 全局任务历史(精修/补充统一):时间/类型/状态/耗时/成本/模型/
5+
* 错误全量可见(数据源 ai_task_history——SQLite ai_tasks 表);
6+
* 成功任务可「查看结果」重新取回(重启恢复后的任务也能查,
7+
* 结果在内存注册表/DB)并可「采纳落库」(带 taskId 标记采纳,
8+
* 防重复采纳产生重复笔记);失败任务显示原因可重试(回到原入口)。
9+
* @ai-context: 与内联卡片(AiRefineCard/EnrichPanel)互补:卡片=进行中交互,
10+
* 面板=历史 + 恢复 + 采纳复核;完成通知由 App.tsx 全局监听
11+
* ai:task-update 事件实现(跨页面可见)。
12+
*/
13+
import { useCallback, useEffect, useState } from "react";
14+
import { invoke } from "@tauri-apps/api/core";
15+
import type { AiTaskRecord } from "../types";
16+
17+
const btn: React.CSSProperties = { padding: "3px 8px", fontSize: 11, borderRadius: 5, border: "1px solid #d1d5db", background: "#fff", cursor: "pointer" };
18+
19+
/** 状态 → 徽标色/文案 */
20+
function stateBadge(state: string): { text: string; color: string; bg: string } {
21+
switch (state) {
22+
case "succeeded": return { text: "✅ 成功", color: "#047857", bg: "#ecfdf5" };
23+
case "failed": return { text: "❌ 失败", color: "#b91c1c", bg: "#fef2f2" };
24+
case "running": return { text: "⏳ 进行中", color: "#1d4ed8", bg: "#eff6ff" };
25+
default: return { text: "⏸ 排队中", color: "#92400e", bg: "#fffbeb" };
26+
}
27+
}
28+
29+
/** 类型 → 标签 */
30+
function opLabel(op: string): string {
31+
return op === "refine" ? "✨ 精修" : "📖 补充";
32+
}
33+
34+
/** 时长格式化(ms → 秒/分) */
35+
function fmtMs(ms: number | null): string {
36+
if (ms == null) return "—";
37+
if (ms < 1000) return `${ms}ms`;
38+
const s = ms / 1000;
39+
if (s < 60) return `${s.toFixed(1)}s`;
40+
return `${Math.floor(s / 60)}m${Math.floor(s % 60)}s`;
41+
}
42+
43+
export default function AiTaskPanel() {
44+
const [tasks, setTasks] = useState<AiTaskRecord[]>([]);
45+
const [msg, setMsg] = useState("");
46+
// 选中任务(查看结果/采纳)
47+
const [selected, setSelected] = useState<AiTaskRecord | null>(null);
48+
const [summary, setSummary] = useState<{ label: string; value: string }[] | null>(null);
49+
50+
const load = useCallback(async () => {
51+
try {
52+
const refine = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "refine", limit: 30 }).catch(() => [] as AiTaskRecord[]);
53+
const enrich = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "enrich", limit: 30 }).catch(() => [] as AiTaskRecord[]);
54+
// 合并按时间倒序(createdAt desc)
55+
setTasks([...refine, ...enrich].sort((a, b) => b.createdAt - a.createdAt).slice(0, 30));
56+
} catch (e) {
57+
setMsg(`加载任务历史失败:${e}`);
58+
}
59+
}, []);
60+
61+
useEffect(() => {
62+
void load();
63+
}, [load]);
64+
65+
/** 查看成功任务结果(取回注册表/恢复结果——重启后仍可查) */
66+
const viewResult = async (t: AiTaskRecord) => {
67+
setMsg("");
68+
try {
69+
const r = t.opType === "refine"
70+
? await invoke<any>("ai_refine_result", { taskId: t.taskId })
71+
: await invoke<any>("ai_enrich_result", { taskId: t.taskId });
72+
setSelected(t);
73+
setSummary(
74+
t.opType === "refine"
75+
? [
76+
{ label: "标题", value: r.title },
77+
{ label: "新增行", value: String(r.addedLines) },
78+
{ label: "删除行", value: String(r.removedLines) },
79+
{ label: "切片", value: String(r.slices) },
80+
{ label: "模型", value: r.model },
81+
]
82+
: [
83+
{ label: "块数", value: String(r.blocks) },
84+
{ label: "深度", value: String(r.depthBlocks) },
85+
{ label: "广度", value: String(r.breadthBlocks) },
86+
{ label: "切片", value: String(r.slices) },
87+
{ label: "模型", value: r.model },
88+
],
89+
);
90+
} catch (e) {
91+
setMsg(`取回结果失败:${e}`);
92+
}
93+
};
94+
95+
/** 采纳落库(带 taskId 标记采纳——防重复采纳) */
96+
const adopt = async () => {
97+
if (!selected) return;
98+
setMsg("");
99+
try {
100+
const r = selected.opType === "refine"
101+
? await invoke<any>("ai_refine_result", { taskId: selected.taskId })
102+
: await invoke<any>("ai_enrich_result", { taskId: selected.taskId });
103+
const note = selected.opType === "refine"
104+
? await invoke<{ id: number }>("ai_refine_apply", { sessionId: selected.refId, result: r, taskId: selected.taskId })
105+
: await invoke<{ id: number }>("ai_enrich_apply", { noteId: selected.refId, result: r, taskId: selected.taskId });
106+
setMsg(`已落库为笔记 #${note.id}(可到笔记页查看版本时间线)`);
107+
setSelected(null);
108+
setSummary(null);
109+
void load();
110+
} catch (e) {
111+
setMsg(`采纳失败:${e}`);
112+
}
113+
};
114+
115+
return (
116+
<div>
117+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
118+
<span style={{ fontWeight: 600, fontSize: 12 }}>🗂 AI 任务中心</span>
119+
<button style={btn} onClick={() => void load()}>刷新</button>
120+
<span style={{ fontSize: 11, color: "#9ca3af" }}>历史任务持久化保存——重启后可查看/采纳结果</span>
121+
</div>
122+
123+
{tasks.length === 0 ? (
124+
<div style={{ fontSize: 11, color: "#9ca3af", marginBottom: 6 }}>暂无任务记录(精修/补充完成后自动记录)</div>
125+
) : (
126+
<div style={{ maxHeight: 200, overflowY: "auto", border: "1px solid #e5e7eb", borderRadius: 6 }}>
127+
{tasks.map((t) => {
128+
const badge = stateBadge(t.state);
129+
return (
130+
<div key={t.taskId} style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 8px", borderBottom: "1px solid #f3f4f6", fontSize: 11 }}>
131+
<span style={{ color: "#6b7280", width: 50, flexShrink: 0 }}>
132+
{new Date(t.createdAt * 1000).toLocaleTimeString()}
133+
</span>
134+
<span style={{ width: 44, flexShrink: 0 }}>{opLabel(t.opType)}</span>
135+
<span style={{ color: badge.color, background: badge.bg, borderRadius: 10, padding: "1px 8px", width: 58, textAlign: "center" }}>
136+
{badge.text}
137+
</span>
138+
<span style={{ color: "#374151", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
139+
{t.opType === "refine" ? `会话 #${t.refId}` : `笔记 #${t.refId}`}
140+
{t.model && <span style={{ color: "#9ca3af" }}> · {t.model}</span>}
141+
</span>
142+
<span style={{ color: "#9ca3af", width: 52, textAlign: "right" }}>{fmtMs(t.elapsedMs)}</span>
143+
{t.costYuan != null && (
144+
<span style={{ color: "#b45309", width: 52, textAlign: "right" }}>¥{t.costYuan.toFixed(4)}</span>
145+
)}
146+
{t.state === "succeeded" && (
147+
<button style={btn} onClick={() => void viewResult(t)}>查看</button>
148+
)}
149+
{t.state === "failed" && t.error && (
150+
<span style={{ color: "#b91c1c", maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={t.error}>
151+
{t.error}
152+
</span>
153+
)}
154+
</div>
155+
);
156+
})}
157+
</div>
158+
)}
159+
160+
{/* 结果摘要 + 采纳(防重复采纳:采纳后任务标记 adopted,历史中不再可恢复) */}
161+
{selected && summary && (
162+
<div style={{ border: "1px solid #a7f3d0", background: "#ecfdf5", borderRadius: 6, padding: 8, marginTop: 6, fontSize: 11 }}>
163+
<div style={{ fontWeight: 600, marginBottom: 4 }}>任务 #{selected.taskId} 结果</div>
164+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 6 }}>
165+
{summary.map((s) => (
166+
<span key={s.label} style={{ color: "#374151" }}>
167+
{s.label}:<strong>{s.value}</strong>
168+
</span>
169+
))}
170+
</div>
171+
<div style={{ display: "flex", gap: 6 }}>
172+
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => void adopt()}>
173+
✅ 采纳落库
174+
</button>
175+
<button style={btn} onClick={() => { setSelected(null); setSummary(null); }}>关闭</button>
176+
</div>
177+
</div>
178+
)}
179+
180+
{msg && <div style={{ fontSize: 11, color: msg.startsWith("已") ? "#0d9488" : "#dc2626", marginTop: 4 }}>{msg}</div>}
181+
</div>
182+
);
183+
}

‎app/src/pages/ClassroomPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { SystemStatusBadge } from "../components/SystemStatusBadge";
3232
// 2026-08-20 用户需求:模型管理面板(原结构分析模型改名,覆盖全部模型)
3333
import ModelManagementPanel from "../components/ModelManagementPanel";
3434
import AiServicePanel from "../components/AiServicePanel";
35+
// v0.8.0 F2(2026-08-21):AI 任务中心面板(历史/恢复/采纳复核)
36+
import AiTaskPanel from "../components/AiTaskPanel";
3537
// 2026-08 审查硬拆:右栏内容区 / 文件素材输入与提取
3638
import ClassroomRightPane from "../components/ClassroomRightPane";
3739
import MaterialInputPanel from "../components/MaterialInputPanel";
@@ -546,6 +548,10 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
546548
存储)/余额查询/授权默认关+审计可见化 */}
547549
<div style={panel}>
548550
<AiServicePanel />
551+
{/* v0.8.0 F2(2026-08-21):AI 任务中心——历史持久化/重启恢复/采纳复核 */}
552+
<div style={{ marginTop: 8 }}>
553+
<AiTaskPanel />
554+
</div>
549555
</div>
550556

551557
{/* 词表管理(v0.4.0 M5:REQ-040:热词/替换词闭环 + 课件预热) */}

‎app/src/types.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,27 @@ export interface AiAuditEntry {
682682
result: string;
683683
}
684684

685+
/** AI 任务记录(F2 任务中心;Rust db_ai_tasks::AiTaskRecord;camelCase 契约) */
686+
export interface AiTaskRecord {
687+
taskId: number;
688+
/** refine | enrich */
689+
opType: string;
690+
/** 精修=会话 id、补充=笔记 id */
691+
refId: number;
692+
/** pending|running|succeeded|failed */
693+
state: string;
694+
resultJson: string | null;
695+
costYuan: number | null;
696+
elapsedMs: number | null;
697+
model: string | null;
698+
error: string | null;
699+
slices: number | null;
700+
createdAt: number;
701+
finishedAt: number | null;
702+
/** 是否已采纳落库(防重启后重复采纳) */
703+
adopted: boolean;
704+
}
705+
685706
// ────────────────────────────────────────────────────────────
686707
// AI 精修类型(v0.8.0 M2,REQ-141/145;Rust serde 契约)
687708
// ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)