|
| 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 | +} |
0 commit comments