Skip to content

Commit 23b349a

Browse files
committed
fix(app): AI 任务视图刷新与 target_kind 口径修复
- ChatPage:active 门控切回重同步(App 注入)+ 常驻订阅 ai:task-update 终态事件重拉任务(保活挂载下别页发起的精修/补充完成后侧栏即时新增; 静态标题映射 10s 时效重拉,dock F9 同口径) - 标题解析统一走 entityLabel.taskRefLabel:refine 按 target_kind 分流、 enrich 恒笔记级、NULL 旧数据 session 兜底(笔记级精修原错查会话标题表) - AiTaskPanel 采纳按 target_kind 分发:笔记级走 ai_note_refine_apply (原恒传 sessionId 报「会话不存在」) - ChatSidebar AI 任务段加「非聊天会话」语义说明(tooltip + 一行文案)
1 parent 90ba5e1 commit 23b349a

7 files changed

Lines changed: 189 additions & 47 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,11 @@ function App() {
364364
<ActionPage active={page === "action"} />
365365
</div>
366366
<div style={{ flex: 1, display: page === "chat" ? "block" : "none", overflow: "hidden" }}>
367-
{/* v0.16.0:AI 对话页——跨页跳转复用 focus 机制(任务对话引用 → 会话/笔记/设置) */}
367+
{/* v0.16.0:AI 对话页——跨页跳转复用 focus 机制(任务对话引用 → 会话/笔记/设置)。
368+
2026-09-09 批 1:active 门控透传——保活挂载下切回重同步(别页发起/完成的
369+
AI 任务本页无感知;SessionsPage/ActionPage 同款 active 语义) */}
368370
<ChatPage
371+
active={page === "chat"}
369372
onOpenSessions={(id) => { setFocusSessionId(id); setPage("sessions"); }}
370373
onOpenNote={(id) => { setFocusNoteId(id); setPage("notes"); }}
371374
onOpenNoteHighlight={(noteId, search) => {

‎app/src/components/AiConversationDock.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { invoke } from "@tauri-apps/api/core";
1919
import { listen } from "@tauri-apps/api/event";
2020
import type { AiTaskRecord, AiTaskState, AiTurn, ChatMessage, ChatSession } from "../types";
2121
import TaskConversationView from "./TaskConversationView";
22-
import { refLabel } from "../utils/entityLabel";
22+
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——笔记级精修
23+
// ref_id=笔记 id,仅按 opType 会错查会话标题表;enrich 恒笔记级)
24+
import { taskRefLabel } from "../utils/entityLabel";
2325

2426
interface SessionRow { id: number; title: string }
2527
interface Props {
@@ -115,9 +117,7 @@ export default function AiConversationDock({
115117
}, []);
116118

117119
const taskRefTitle = useCallback((t: AiTaskRecord): string =>
118-
t.opType === "refine"
119-
? refLabel("session", sessionTitles.get(t.refId))
120-
: refLabel("note", noteTitles.get(t.refId)),
120+
taskRefLabel(t, sessionTitles, noteTitles),
121121
[sessionTitles, noteTitles]);
122122

123123
// F9:懒加载 + open 翻转刷新——首次 open 装载;之后静态数据 10s 时效跳过,

‎app/src/components/AiTaskPanel.tsx‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,33 @@ export default function AiTaskPanel() {
105105
}
106106
setMsg("");
107107
try {
108-
// 采纳落库(返回 {id} 仅历史协议——REQ-277 后不再向用户展示裸编号)
109-
await (selected.opType === "refine"
110-
? invoke<{ id: number }>("ai_refine_apply", {
111-
sessionId: selected.refId,
112-
result: await invoke<AiRefineResult>("ai_refine_result", { taskId: selected.taskId }),
113-
taskId: selected.taskId,
114-
})
115-
: invoke<{ id: number }>("ai_enrich_apply", {
108+
// 采纳落库(返回 {id} 仅历史协议——REQ-277 后不再向用户展示裸编号)。
109+
// 2026-09-09 批 1 修复:精修按 target_kind 分发采纳链——笔记级任务
110+
// (ref_id=笔记 id)走 ai_note_refine_apply(笔记现有版本上加 ai-refine
111+
// 版本),会话级/NULL 旧数据走 ai_refine_apply(建基线笔记)——
112+
// 原恒传 sessionId 使笔记级任务误入会话链报「会话不存在」。
113+
if (selected.opType === "refine") {
114+
const result = await invoke<AiRefineResult>("ai_refine_result", { taskId: selected.taskId });
115+
if (selected.targetKind === "note") {
116+
await invoke("ai_note_refine_apply", {
116117
noteId: selected.refId,
117-
result: await invoke<AiEnrichResult>("ai_enrich_result", { taskId: selected.taskId }),
118+
result,
119+
taskId: selected.taskId,
120+
});
121+
} else {
122+
await invoke<{ id: number }>("ai_refine_apply", {
123+
sessionId: selected.refId,
124+
result,
118125
taskId: selected.taskId,
119-
}));
126+
});
127+
}
128+
} else {
129+
await invoke<{ id: number }>("ai_enrich_apply", {
130+
noteId: selected.refId,
131+
result: await invoke<AiEnrichResult>("ai_enrich_result", { taskId: selected.taskId }),
132+
taskId: selected.taskId,
133+
});
134+
}
120135
// REQ-277:落库提示不带裸 # 数字(笔记身份=语义位置,不是编号)
121136
setMsg("已落库为笔记(可到笔记页查看版本时间线)");
122137
setSelected(null);

‎app/src/components/ChatSidebar.tsx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* 全部数据由 ChatPage 加载后透传(本组件纯展示 + 事件回调)。
77
*/
88
import type { AiTaskRecord, ChatSession } from "../types";
9-
import { refLabel } from "../utils/entityLabel";
9+
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——会话级/笔记级
10+
// 精修 ref_id 语义不同;enrich 恒笔记级),侧栏与对话页/dock 同口径
11+
import { taskRefLabel } from "../utils/entityLabel";
1012

1113
/** 任务类型标签(refine/enrich → 中文 + 图标;模块内消费——审查修复:原
1214
* export 无外部消费方,收窄为非导出) */
@@ -118,12 +120,20 @@ export default function ChatSidebar(props: Props) {
118120
</div>
119121
))}
120122

121-
<div style={{ fontSize: 12, fontWeight: 600, color: "#6b7280", padding: "14px 8px 4px" }}>🤖 AI 任务</div>
123+
<div
124+
style={{ fontSize: 12, fontWeight: 600, color: "#6b7280", padding: "14px 8px 4px" }}
125+
title="AI 精修/补充任务的轨迹条目(来源=会话或笔记),不是上方 💬 聊天会话——点选查看轨迹与采纳入口"
126+
>🤖 AI 任务</div>
122127
{tasks.length === 0 && <div style={{ fontSize: 12, color: "#9ca3af", padding: "4px 8px" }}>暂无精修/补充任务</div>}
128+
{/* 2026-09-09 批 1 语义说明:本段是精修/补充任务(来源会话/笔记)的
129+
只读轨迹视图,不是 💬 聊天会话——点选打开任务对话、结果可采纳 */}
130+
{tasks.length > 0 && (
131+
<div style={{ fontSize: 10.5, color: "#9ca3af", lineHeight: 1.6, padding: "0 8px 6px" }}>
132+
精修/补充任务的轨迹(来源:会话或笔记)——非聊天会话;完成结果可采纳
133+
</div>
134+
)}
123135
{tasks.map((t) => {
124-
const refName = t.opType === "refine"
125-
? refLabel("session", sessionTitles.get(t.refId))
126-
: refLabel("note", noteTitles.get(t.refId));
136+
const refName = taskRefLabel(t, sessionTitles, noteTitles);
127137
const stateBadge = t.state === "succeeded" ? "#047857" : t.state === "failed" ? "#b91c1c" : "#b45309";
128138
return (
129139
<div

‎app/src/pages/ChatPage.tsx‎

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
import { useCallback, useEffect, useRef, useState } from "react";
1313
import { invoke } from "@tauri-apps/api/core";
14+
import { listen } from "@tauri-apps/api/event";
1415
import { confirm } from "@tauri-apps/plugin-dialog";
1516
import type { AiTaskRecord, AiProviderView, ChatMessage, ChatSession, ChatStreamEvent, NoteGroup } from "../types";
1617
import ChatSidebar from "../components/ChatSidebar";
@@ -25,9 +26,14 @@ import { buildConversationMarkdown } from "../utils/chatTranscript";
2526
import TaskLaunchDialog, { type LaunchTargetRow } from "../components/TaskLaunchDialog";
2627
import TaskThreadCard from "../components/TaskThreadCard";
2728
import { buildTaskFollowUpPrompt } from "../utils/taskFollowUp";
28-
import { refLabel } from "../utils/entityLabel";
29+
// 2026-09-09 批 1:任务标题统一按类别解析(taskRefLabel——笔记级精修
30+
// ref_id=笔记 id,仅按 opType 会错查会话标题表;enrich 恒笔记级)
31+
import { taskRefLabel } from "../utils/entityLabel";
2932

3033
interface Props {
34+
/** 页面可见门控(App 层注入——保活挂载下切回重同步;2026-09-09 批 1:
35+
别页发起/完成的 AI 任务本页无感知,切回必须重拉。SessionsPage 同款语义) */
36+
active: boolean;
3137
/** 跨页直达(任务对话引用跳转) */
3238
onOpenSessions: (sessionId: number) => void;
3339
onOpenNote: (noteId: number) => void;
@@ -53,11 +59,15 @@ interface SessionRow {
5359
/** 首次发送云端提示的记忆键(一次性确认) */
5460
const CLOUD_NOTICE_KEY = "entropy-ai-chat-cloud-notice";
5561

62+
/** 静态数据时效(ms):active 切回距上次拉取 <10s 则跳过——列表不抖动
63+
* (AiConversationDock F9 同口径) */
64+
const STATIC_TTL_MS = 10_000;
65+
5666
const CLOUD_NOTICE_TEXT =
5767
"对话内容(纯文本)将发送至所选模型的云端服务商;本地音视频/图片/笔记永不出本机。是否同意?";
5868

5969
export default function ChatPage(props: Props) {
60-
const { onOpenSessions, onOpenNote, onOpenNoteHighlight, onOpenSettings, focusTaskId, onFocusTaskConsumed, focusChatId, onFocusChatConsumed, onOpenRefineWorkbench } = props;
70+
const { active, onOpenSessions, onOpenNote, onOpenNoteHighlight, onOpenSettings, focusTaskId, onFocusTaskConsumed, focusChatId, onFocusChatConsumed, onOpenRefineWorkbench } = props;
6171
const [sessions, setSessions] = useState<ChatSession[]>([]);
6272
const [tasks, setTasks] = useState<AiTaskRecord[]>([]);
6373
const [providers, setProviders] = useState<AiProviderView[]>([]);
@@ -71,7 +81,6 @@ export default function ChatPage(props: Props) {
7181
const [retryBusy, setRetryBusy] = useState(false);
7282
const [sessionTitles, setSessionTitles] = useState<Map<number, string>>(new Map());
7383
const [noteTitles, setNoteTitles] = useState<Map<number, string>>(new Map());
74-
const [staticLoaded, setStaticLoaded] = useState(false);
7584
// v0.16.1:对话转笔记——组列表(目标组下拉)与保存对话框态
7685
const [noteGroups, setNoteGroups] = useState<NoteGroup[]>([]);
7786
const [saveDialog, setSaveDialog] = useState<{ initialTitle: string; content: string } | null>(null);
@@ -83,6 +92,10 @@ export default function ChatPage(props: Props) {
8392
const [noteRows, setNoteRows] = useState<LaunchTargetRow[]>([]);
8493
const activeChatRef = useRef<number | null>(null);
8594
activeChatRef.current = activeChatId;
95+
// 2026-09-09 批 1:activeTaskId ref 镜像——ai:task-update 监听只注册一次,
96+
// 回调读最新选中任务(AiConversationDock F2 同款——选中切换不触发重订阅)
97+
const activeTaskIdRef = useRef<number | null>(null);
98+
activeTaskIdRef.current = activeTaskId;
8699
// F3(审查竞态):消息装载序号——并发装载只认最新(快速切会话时旧会话的
87100
// 异步返回不得覆盖新会话消息;与 selectChat 的同步 set 无关,纯防错序)
88101
const loadSeq = useRef(0);
@@ -101,34 +114,44 @@ export default function ChatPage(props: Props) {
101114

102115
const { view, setActive, isStreaming, launch, stop } = useChatStream((sid, ev) => void onStreamSettled(sid, ev));
103116

104-
// 静态数据(会话/Provider/标题映射)只加载一次——审查优化:原 6s 全量刷新
105-
useEffect(() => {
106-
if (staticLoaded) return;
107-
void (async () => {
108-
setSessions(await invoke<ChatSession[]>("chat_list_sessions").catch(() => [] as ChatSession[]));
109-
setProviders(await invoke<AiProviderView[]>("ai_provider_list").catch(() => [] as AiProviderView[]));
110-
const sessRows = await invoke<SessionRow[]>("list_sessions", { limit: 500 }).catch(() => [] as SessionRow[]);
111-
setSessionTitles(new Map(sessRows.map((s) => [s.id, s.title])));
112-
setSessionRows(sessRows.map((s) => ({ id: s.id, title: s.title })));
113-
const notes = await invoke<{ id: number; title: string }[]>("search_notes", { keyword: "", tag: null as string | null }).catch(() => [] as { id: number; title: string }[]);
114-
setNoteTitles(new Map(notes.map((n) => [n.id, n.title])));
115-
setNoteRows(notes.map((n) => ({ id: n.id, title: n.title })));
116-
// v0.16.1:组列表(保存对话框目标组下拉——失败静默仅无组可选)
117-
setNoteGroups(await invoke<NoteGroup[]>("list_note_groups", { terrain: null }).catch(() => [] as NoteGroup[]));
118-
setStaticLoaded(true);
119-
})();
120-
}, [staticLoaded]);
121-
122-
// 任务列表:初始 + 仅当存在未终态任务时轮询(审查优化:无进行中任务零轮询)
117+
// 静态数据(会话/Provider/标题/组映射)——2026-09-09 批 1:原「挂载即拉
118+
// 一次」在保活挂载(TD-004)下等于应用启动即在隐藏页拉取;改为首激活拉取
119+
// + 切回超 STATIC_TTL_MS 时效才重拉(AiConversationDock F9 同口径防抖动)。
120+
// 标题映射覆盖会话/笔记全量——AI 任务侧栏条目按 ref_id 查表(taskRefLabel)。
121+
const staticAtRef = useRef(0);
122+
const loadStatic = useCallback(async () => {
123+
staticAtRef.current = Date.now();
124+
setSessions(await invoke<ChatSession[]>("chat_list_sessions").catch(() => [] as ChatSession[]));
125+
setProviders(await invoke<AiProviderView[]>("ai_provider_list").catch(() => [] as AiProviderView[]));
126+
const sessRows = await invoke<SessionRow[]>("list_sessions", { limit: 500 }).catch(() => [] as SessionRow[]);
127+
setSessionTitles(new Map(sessRows.map((s) => [s.id, s.title])));
128+
setSessionRows(sessRows.map((s) => ({ id: s.id, title: s.title })));
129+
const notes = await invoke<{ id: number; title: string }[]>("search_notes", { keyword: "", tag: null as string | null }).catch(() => [] as { id: number; title: string }[]);
130+
setNoteTitles(new Map(notes.map((n) => [n.id, n.title])));
131+
setNoteRows(notes.map((n) => ({ id: n.id, title: n.title })));
132+
// v0.16.1:组列表(保存对话框目标组下拉——失败静默仅无组可选)
133+
setNoteGroups(await invoke<NoteGroup[]>("list_note_groups", { terrain: null }).catch(() => [] as NoteGroup[]));
134+
}, []);
135+
136+
// 任务列表拉取(AI 任务段 + 线程卡数据源);拉取时机见下方 active 切回/
137+
// ai:task-update 终态事件,进行中任务由轮询 effect 兜底
123138
const reloadTasks = useCallback(async () => {
124139
const refine = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "refine", limit: 30 }).catch(() => [] as AiTaskRecord[]);
125140
const enrich = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "enrich", limit: 30 }).catch(() => [] as AiTaskRecord[]);
126141
setTasks([...refine, ...enrich].sort((a, b) => b.createdAt - a.createdAt).slice(0, 60));
127142
}, []);
128143

144+
// 2026-09-09 批 1(刷新缺口根治):active 门控切回重同步——本页保活挂载、
145+
// 隐藏期别页(会话页精修/笔记级 AI)发起/完成的任务本页无感知;每次切回
146+
// 重拉任务列表,静态数据(会话/笔记标题映射)超时效才重拉(隐藏期可能有
147+
// 新建/改名实体——标题查表要拿得到,否则任务条目回落中性占位)
129148
useEffect(() => {
149+
if (!active) return;
150+
if (staticAtRef.current === 0 || Date.now() - staticAtRef.current >= STATIC_TTL_MS) {
151+
void loadStatic();
152+
}
130153
void reloadTasks();
131-
}, [reloadTasks]);
154+
}, [active, loadStatic, reloadTasks]);
132155

133156
const hasActiveTask = tasks.some((t) => t.state === "pending" || t.state === "running");
134157
useEffect(() => {
@@ -162,16 +185,38 @@ export default function ChatPage(props: Props) {
162185
}
163186
}, [setActive]);
164187

188+
// 2026-09-09 批 1(刷新缺口根治):订阅 ai:task-update **终态**事件 → 重拉
189+
// 任务列表——会话页/笔记页发起的精修/补充在本页(保活挂载)不可见的问题
190+
// 根源是本页无事件通道;成功/失败判定同 App.tsx 全局 toast 口径(进行中
191+
// 按片进度事件不刷列表,省无谓 IPC)。选中任务详情打开时终态后重取详情
192+
// (轨迹/adopted 回写同 AiConversationDock F2——ref 镜像读最新选中)。
193+
// 注:Rust 侧事件先于 DB 终态落库发出(set_task → finish_ai_task)——理论
194+
// 竞态下本次刷新读到 running,由下方 hasActiveTask 轮询兜底收敛(不额外延时)
195+
useEffect(() => {
196+
let disposed = false;
197+
let unlisten: (() => void) | undefined;
198+
void listen<[number, import("../types").AiTaskState]>("ai:task-update", (e) => {
199+
if (disposed) return;
200+
const st = e.payload[1];
201+
const terminal = st === "Succeeded" || (typeof st === "object" && st !== null && "Failed" in st);
202+
if (!terminal) return;
203+
void reloadTasks();
204+
const tid = activeTaskIdRef.current;
205+
if (tid != null) void selectTask(tid);
206+
}).then((u) => {
207+
if (disposed) u(); else unlisten = u;
208+
});
209+
return () => { disposed = true; unlisten?.(); };
210+
}, [reloadTasks, selectTask]);
211+
165212
const refreshSessions = useCallback(async () => {
166213
setSessions(await invoke<ChatSession[]>("chat_list_sessions").catch(() => [] as ChatSession[]));
167214
}, []);
168215

169216
// v0.16.1:任务对话化——目标名解析 / 追问预填 / 启动成功(REQ-277:
170217
// 标题缺失语义占位——绝不回退裸 `#id`)
171218
const taskRefTitle = useCallback((t: import("../types").AiTaskRecord): string =>
172-
t.opType === "refine"
173-
? refLabel("session", sessionTitles.get(t.refId))
174-
: refLabel("note", noteTitles.get(t.refId)),
219+
taskRefLabel(t, sessionTitles, noteTitles),
175220
[sessionTitles, noteTitles]);
176221
const followUpTask = useCallback((t: import("../types").AiTaskRecord) => {
177222
setDraft(buildTaskFollowUpPrompt(t, taskRefTitle(t)));

‎app/src/utils/entityLabel.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* entityLabel.test — 语义标签纯函数单测(REQ-277 裸号治理)。
3+
* 2026-09-09 批 1:taskRefKind/taskRefLabel(AI 任务标题按 target_kind 分发)。
34
*/
45
import { describe, expect, it } from "vitest";
5-
import { kindWord, refLabel } from "./entityLabel";
6+
import { kindWord, refLabel, taskRefKind, taskRefLabel } from "./entityLabel";
67

78
describe("refLabel(标题优先,缺标题语义占位——绝不回退裸 # 数字)", () => {
89
it("有标题 → 原样返回(含空白裁剪)", () => {
@@ -28,3 +29,38 @@ describe("kindWord(类别词——无 id)", () => {
2829
expect(kindWord("note")).toBe("笔记");
2930
});
3031
});
32+
33+
describe("taskRefKind(AI 任务来源类别——2026-09-09 批 1 双入口分发)", () => {
34+
it("会话级精修(targetKind=session/NULL 旧数据)→ session", () => {
35+
expect(taskRefKind({ opType: "refine", refId: 1 })).toBe("session");
36+
expect(taskRefKind({ opType: "refine", targetKind: null, refId: 1 })).toBe("session");
37+
expect(taskRefKind({ opType: "refine", targetKind: "session", refId: 1 })).toBe("session");
38+
});
39+
40+
it("笔记级精修(targetKind=note)→ note(ref_id=笔记 id,仅按 opType 会错查会话标题)", () => {
41+
expect(taskRefKind({ opType: "refine", targetKind: "note", refId: 5 })).toBe("note");
42+
});
43+
44+
it("补充恒为笔记级(含 v0.17.0 前 target_kind=NULL 旧数据——入参即 note_id 不猜 session)", () => {
45+
expect(taskRefKind({ opType: "enrich", refId: 5 })).toBe("note");
46+
expect(taskRefKind({ opType: "enrich", targetKind: "note", refId: 5 })).toBe("note");
47+
});
48+
});
49+
50+
describe("taskRefLabel(AI 任务标题查表——按类别取对应标题映射)", () => {
51+
const sessionTitles = new Map<number, string>([[1, "会话甲"], [2, "会话乙"]]);
52+
const noteTitles = new Map<number, string>([[5, "笔记五"]]);
53+
54+
it("会话级精修 → 会话标题;笔记级精修 → 笔记标题(不复用对方表)", () => {
55+
expect(taskRefLabel({ opType: "refine", targetKind: "session", refId: 1 }, sessionTitles, noteTitles)).toBe("会话甲");
56+
expect(taskRefLabel({ opType: "refine", targetKind: "note", refId: 5 }, sessionTitles, noteTitles)).toBe("笔记五");
57+
// 修复回归形状:笔记级任务 id 恰好与会话 id 同名时不得串表(note=5 在会话表缺位 → 中性占位)
58+
expect(taskRefLabel({ opType: "refine", targetKind: "note", refId: 5 }, sessionTitles)).toBe("笔记(标题不可用)");
59+
expect(taskRefLabel({ opType: "refine", targetKind: "session", refId: 5 }, sessionTitles, noteTitles)).toBe("会话(标题不可用)");
60+
});
61+
62+
it("缺标题 → 类别中性占位;查表缺失与空映射一致", () => {
63+
expect(taskRefLabel({ opType: "refine", targetKind: "note", refId: 999 }, sessionTitles, noteTitles)).toBe("笔记(标题不可用)");
64+
expect(taskRefLabel({ opType: "enrich", refId: 999 }, sessionTitles)).toBe("笔记(标题不可用)");
65+
});
66+
});

0 commit comments

Comments
 (0)