diff --git a/desktop/build.rs b/desktop/build.rs index 8c253940..b34a8cde 100644 --- a/desktop/build.rs +++ b/desktop/build.rs @@ -89,6 +89,7 @@ fn main() { "mc_projects", "mc_task_info", "mc_task_rounds", + "mc_task_user_inputs", "mc_task_stop", "mc_task_delete", "mc_task_create", diff --git a/desktop/src/baizhi/mod.rs b/desktop/src/baizhi/mod.rs index 932624f3..77151031 100644 --- a/desktop/src/baizhi/mod.rs +++ b/desktop/src/baizhi/mod.rs @@ -754,6 +754,20 @@ pub async fn mc_task_rounds( .map_err(BzErr::msg) } +#[tauri::command] +pub async fn mc_task_user_inputs( + bz: State<'_, BaizhiState>, + id: String, + cursor: Option, + limit: Option, +) -> Result { + // 后端上限 100;大纲一次多拿些,减少全量拉取的往返数 + let limit = limit.unwrap_or(100).clamp(1, 100); + monkeycode::mc_task_user_inputs(&bz.0, &id, cursor.as_deref().unwrap_or(""), limit) + .await + .map_err(BzErr::msg) +} + #[tauri::command] pub async fn mc_task_stop(bz: State<'_, BaizhiState>, id: String) -> Result { monkeycode::mc_task_stop(&bz.0, &id).await.map_err(BzErr::msg)?; diff --git a/desktop/src/baizhi/monkeycode.rs b/desktop/src/baizhi/monkeycode.rs index e5c3035d..d4095979 100644 --- a/desktop/src/baizhi/monkeycode.rs +++ b/desktop/src/baizhi/monkeycode.rs @@ -315,6 +315,17 @@ pub async fn mc_task_rounds(svc: &Service, id: &str, cursor: &str, limit: u32) - })) } +/// 云端任务提问索引(倒序,cursor 向更早翻页;{items, next_cursor, has_more} +/// 原样透传 UI)。content 已是解码明文(超 500 字符截断),timestamp 纳秒、 +/// 与 chunk.timestamp 对齐——UI 的提问大纲靠它与帧流对表。 +pub async fn mc_task_user_inputs(svc: &Service, id: &str, cursor: &str, limit: u32) -> BzResult { + let mut path = format!("/api/v1/users/tasks/user-inputs?id={}&limit={limit}", urlencode(id)); + if !cursor.is_empty() { + path.push_str(&format!("&cursor={}", urlencode(cursor))); + } + mc_call(svc, reqwest::Method::GET, &path, None).await +} + /// 终止云端任务(区别于 WS 上行 user-cancel:那只中断当前执行)。 pub async fn mc_task_stop(svc: &Service, id: &str) -> BzResult<()> { mc_call(svc, reqwest::Method::PUT, "/api/v1/users/tasks/stop", Some(&json!({ "id": id }))) diff --git a/desktop/src/baizhi/tests.rs b/desktop/src/baizhi/tests.rs index 8294c88c..e6667073 100644 --- a/desktop/src/baizhi/tests.rs +++ b/desktop/src/baizhi/tests.rs @@ -747,6 +747,10 @@ async fn cloud_sidebar_and_task_actions_contract() { ("GET", "/api/v1/users/projects") => { Resp::json(200, json!({ "code": 0, "data": { "projects": [{"id": "p1"}] } })) } + ("GET", "/api/v1/users/tasks/user-inputs") => Resp::json( + 200, + json!({ "code": 0, "data": { "items": [{"id": "user-input-1", "content": "你好", "timestamp": 1_722_000_000_000_000_000_i64}], "next_cursor": "c2", "has_more": true } }), + ), ("PUT", "/api/v1/users/tasks/stop") | ("DELETE", "/api/v1/users/tasks/t1") => { Resp::json(200, json!({ "code": 0, "data": {} })) } @@ -767,6 +771,11 @@ async fn cloud_sidebar_and_task_actions_contract() { assert_eq!(tasks.pointer("/tasks/0/id").and_then(Value::as_str), Some("t1")); let projects = super::monkeycode::mc_projects(&svc).await.map_err(|e| e.msg()).unwrap(); assert_eq!(projects.pointer("/projects/0/id").and_then(Value::as_str), Some("p1")); + // 提问索引(云端大纲):{items, next_cursor, has_more} 原样透传 + let inputs = super::monkeycode::mc_task_user_inputs(&svc, "t1", "c1", 100).await.map_err(|e| e.msg()).unwrap(); + assert_eq!(inputs.pointer("/items/0/content").and_then(Value::as_str), Some("你好")); + assert_eq!(inputs.get("next_cursor").and_then(Value::as_str), Some("c2")); + assert_eq!(inputs.get("has_more").and_then(Value::as_bool), Some(true)); super::monkeycode::mc_task_stop(&svc, "t1").await.map_err(|e| e.msg()).unwrap(); super::monkeycode::mc_task_delete(&svc, "t1").await.map_err(|e| e.msg()).unwrap(); @@ -776,6 +785,13 @@ async fn cloud_sidebar_and_task_actions_contract() { assert!(list.1.contains("project_id=p%2F1")); assert!(list.1.contains("quick_start=true")); assert!(requests.iter().any(|(method, path, _)| method == "GET" && path == "/api/v1/users/projects?limit=50")); + assert!(requests.iter().any(|(method, path, _)| { + method == "GET" + && path.starts_with("/api/v1/users/tasks/user-inputs?") + && path.contains("id=t1") + && path.contains("limit=100") + && path.contains("cursor=c1") + })); assert!(requests.iter().any(|(method, path, body)| method == "PUT" && path == "/api/v1/users/tasks/stop" && body.get("id").and_then(Value::as_str) == Some("t1"))); assert!(requests.iter().any(|(method, path, _)| method == "DELETE" && path == "/api/v1/users/tasks/t1")); } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index c19118ce..93a8d425 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1215,6 +1215,7 @@ fn main() { baizhi::mc_projects, baizhi::mc_task_info, baizhi::mc_task_rounds, + baizhi::mc_task_user_inputs, baizhi::mc_task_stop, baizhi::mc_task_delete, baizhi::mc_task_create, diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index ddcd102d..0abb7573 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -93,6 +93,7 @@ "allow-mc-projects", "allow-mc-task-info", "allow-mc-task-rounds", + "allow-mc-task-user-inputs", "allow-mc-task-stop", "allow-mc-task-delete", "allow-mc-task-create", diff --git a/desktop/ui/src/chat.tsx b/desktop/ui/src/chat.tsx index 1bb889c9..6deb9033 100644 --- a/desktop/ui/src/chat.tsx +++ b/desktop/ui/src/chat.tsx @@ -23,6 +23,7 @@ import { OUTLINE_JUMP_INSET, TaskPanel, ViewHeader, + mergeLiveOutline, outlineActiveSeq, outlineEntries, useRenameDraft, @@ -664,7 +665,11 @@ export function ChatView({ }, [chat.items]); // ==== 提问大纲 ==== - const outline = useMemo(() => outlineEntries(session.outline), [session.outline]); + // 壳目录 + 流内实时用户消息合并:刚发的提问不等轮末物化就进大纲 + const outline = useMemo( + () => outlineEntries(mergeLiveOutline(session.outline, chat.items)), + [session.outline, chat.items], + ); const [activeSeq, setActiveSeq] = useState(undefined); const activeRaf = useRef(0); // 当前视口所在的提问 = 视口顶部之上最后一条用户气泡。判定沿用 saveAnchor diff --git a/desktop/ui/src/cloudOutline.test.ts b/desktop/ui/src/cloudOutline.test.ts new file mode 100644 index 00000000..b9fb8658 --- /dev/null +++ b/desktop/ui/src/cloudOutline.test.ts @@ -0,0 +1,94 @@ +// 云端提问大纲:时间戳锚归一、user-input 帧盖章、REST 索引条目转换。 +// 交互(跳转补页/当前项跟踪)是 DOM 滚动逻辑,靠手动验收 + 本地大纲既有用例守。 +import { describe, expect, it } from "vitest"; + +import { cloudOutlineAnchor, cloudOutlineItems, framesHaveAnchor, withCloudOutlineAnchors } from "./cloudOutline"; +import { mergeLiveOutline } from "./outline"; +import type { Frame, LogItem } from "./types"; + +// 同一时刻的四种精度写法(2026-07-26T00:00:00Z 附近) +const MS = 1_784_937_600_123; +const NS = MS * 1e6; + +describe("cloudOutlineAnchor", () => { + it("纳秒(REST 索引)与毫秒(帧流)归一到同一个 10ms 锚", () => { + expect(cloudOutlineAnchor(NS)).toBe(cloudOutlineAnchor(MS)); + expect(cloudOutlineAnchor(MS)).toBe(Math.floor(MS / 10)); + }); + + it("纳秒超出 JS 安全整数的精度漂移被 10ms 取整吸收", () => { + // 模拟 JSON 解析后的浮点近似(±256ns 级):不该翻过 10ms 边界 + expect(cloudOutlineAnchor(NS + 300)).toBe(cloudOutlineAnchor(NS)); + }); + + it("微秒与秒精度也能对上", () => { + expect(cloudOutlineAnchor(MS * 1e3)).toBe(cloudOutlineAnchor(MS)); + // 秒粒度会丢毫秒尾数,只要求落在同一坐标系(10ms 单位) + expect(cloudOutlineAnchor(1_784_937_600)).toBe(1_784_937_600 * 100); + }); + + it("空值/非法值返回 undefined(条目会被丢弃而不是锚在 0 上)", () => { + expect(cloudOutlineAnchor(undefined)).toBeUndefined(); + expect(cloudOutlineAnchor(0)).toBeUndefined(); + expect(cloudOutlineAnchor(Number.NaN)).toBeUndefined(); + }); +}); + +describe("withCloudOutlineAnchors", () => { + it("user-input 帧的 seq 改写为时间戳锚(chunk seq 是另一套坐标,必须盖掉)", () => { + const frames: Frame[] = [ + { type: "user-input", timestamp: MS, seq: 3 }, + { type: "task-started", timestamp: MS, seq: 4 }, + ]; + const [ui, started] = withCloudOutlineAnchors(frames); + expect(ui.seq).toBe(Math.floor(MS / 10)); + expect(started.seq).toBe(4); // 非 user-input 不动 + }); + + it("缺时间戳的 user-input 保持原样,不喂 0 锚", () => { + const f: Frame = { type: "user-input", seq: 3 }; + expect(withCloudOutlineAnchors([f])[0]).toBe(f); + }); +}); + +describe("framesHaveAnchor", () => { + it("REST 索引锚(纳秒)能在毫秒时间戳的帧集里找到同一条提问", () => { + const frames: Frame[] = [ + { type: "task-started", timestamp: MS }, + { type: "user-input", timestamp: MS }, + ]; + const restAnchor = cloudOutlineAnchor(NS)!; + expect(framesHaveAnchor(frames, restAnchor)).toBe(true); + }); + + it("非 user-input 帧与无时间戳帧不参与判定", () => { + expect(framesHaveAnchor([{ type: "task-started", timestamp: MS }], Math.floor(MS / 10))).toBe(false); + expect(framesHaveAnchor([{ type: "user-input" }], Math.floor(MS / 10))).toBe(false); + }); +}); + +describe("cloudOutlineItems", () => { + it("倒序索引转正序条目,纳秒换算毫秒供时间列展示", () => { + const items = cloudOutlineItems([ + { id: "user-input-2", content: "第二问", timestamp: (MS + 60_000) * 1e6 }, + { id: "user-input-1", content: "第一问", timestamp: NS }, + ]); + expect(items.map((it) => it.text)).toEqual(["第一问", "第二问"]); + expect(items[0].seq).toBe(Math.floor(MS / 10)); + expect(items[0].timestamp).toBe(Math.floor(MS / 10) * 10); + }); + + it("缺时间戳的条目丢弃(没锚定位不了,留着是点不动的死条目)", () => { + expect(cloudOutlineItems([{ id: "x", content: "无时间戳" }])).toEqual([]); + }); + + it("与盖过章的对话流合并:REST 覆盖历史,实时补最新一问,同锚去重", () => { + const rest = cloudOutlineItems([{ content: "第一问", timestamp: NS }]); + const live: LogItem[] = [ + { kind: "user", text: "第一问", seq: Math.floor(MS / 10), timestamp: MS }, + { kind: "user", text: "最新一问", seq: Math.floor((MS + 60_000) / 10), timestamp: MS + 60_000 }, + ]; + const merged = mergeLiveOutline(rest, live); + expect(merged.map((it) => it.text)).toEqual(["第一问", "最新一问"]); + }); +}); diff --git a/desktop/ui/src/cloudOutline.ts b/desktop/ui/src/cloudOutline.ts new file mode 100644 index 00000000..1942f9b7 --- /dev/null +++ b/desktop/ui/src/cloudOutline.ts @@ -0,0 +1,181 @@ +// 云端任务的提问大纲:数据源是 REST 提问索引(users/tasks/user-inputs, +// 倒序游标分页)+ 已归约对话流里的实时用户消息,渲染复用本地的 OutlineNav。 +// +// 与本地的差别只在"锚":本地用壳编的帧 seq 对表,云端没有稳定 seq +// (仅 ClickHouse 存储带,Loki 没有),REST 索引与帧流之间唯一都有的键是 +// 时间戳——REST 是纳秒、帧流是毫秒,且纳秒超出 JS 安全整数会有精度漂移。 +// 对齐 Web(task-user-input-index-model)的方案:统一取整到 10ms 边界, +// 以「10ms 单位的整数」当 seq 喂给现成的大纲组件与 data-mc-seq 定位链。 +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { mcTaskUserInputs } from "./cloudapi"; +import { + OUTLINE_JUMP_INSET, + mergeLiveOutline, + outlineActiveSeq, + outlineEntries, + type OutlineEntry, +} from "./outline"; +import type { CloudUserInputItem, Frame, LogItem } from "./types"; +import type { OutlineItem } from "./useSession"; + +/** 任意精度时间戳 → 10ms 单位锚(REST 纳秒/帧流毫秒都能对上)。 + * 毫秒路径直接整除,不绕道纳秒:ms×1e6 会超出 Number 安全整数, + * 乘完再除的舍入可能把锚推过 10ms 边界,两边就对不上了。 */ +export function cloudOutlineAnchor(ts?: number): number | undefined { + if (ts === undefined || !Number.isFinite(ts) || ts <= 0) return undefined; + if (ts >= 1e17) return Math.floor(ts / 1e7); // ns + if (ts >= 1e14) return Math.floor(ts / 1e4); // µs + if (ts >= 1e11) return Math.floor(ts / 10); // ms + return Math.floor(ts * 100); // s +} + +/** user-input 帧盖章:seq 改写为时间戳锚(在 useCloudTask 的归约边界调用, + * REST 回放与 WS 实时两路都会经过)。覆盖原 chunk seq 是刻意的:那是另一套 + * 坐标(轮次号/帧水位),混用两套锚,索引条目和气泡就对不上号。 */ +export function withCloudOutlineAnchors(frames: Frame[]): Frame[] { + return frames.map((f) => { + if (f.type !== "user-input") return f; + const anchor = cloudOutlineAnchor(f.timestamp); + return anchor === undefined ? f : { ...f, seq: anchor }; + }); +} + +/** 帧集里是否已有该锚的 user-input(大纲跳转的补页终止条件):读内存帧集 + * 而非 DOM,prepend 后立即可判,不依赖 React 提交时序。 */ +export function framesHaveAnchor(frames: Frame[], anchorSeq: number): boolean { + return frames.some((f) => f.type === "user-input" && cloudOutlineAnchor(f.timestamp) === anchorSeq); +} + +/** REST 索引页(倒序)→ 正序 OutlineItem。没有时间戳的条目丢弃: + * 没锚就定位不了,留着只会是点不动的死条目。offset 云端无意义,恒 0。 */ +export function cloudOutlineItems(items: CloudUserInputItem[]): OutlineItem[] { + const out: OutlineItem[] = []; + for (const it of items) { + const anchor = cloudOutlineAnchor(it.timestamp); + if (anchor === undefined) continue; + // 锚是 10ms 单位,×10 回到毫秒(大纲时间列只精确到分,截断无感) + out.push({ seq: anchor, offset: 0, text: it.content ?? "", timestamp: anchor * 10 }); + } + return out.reverse(); +} + +/** 全量拉取的护栏:5 页 × 100 条;超过的更早提问只能靠"加载更早"逐轮补。 */ +const MAX_INDEX_PAGES = 5; + +/** 拉全量提问索引(挂载时一次;运行中新增的提问由实时合并兜住), + * 与对话流合并成大纲条目。索引拿不到不影响任务本身,静默降级为 + * 只有实时条目。 */ +export function useCloudOutline(id: string, items: LogItem[]): OutlineEntry[] { + const [rest, setRest] = useState([]); + useEffect(() => { + setRest([]); + let alive = true; + void (async () => { + const all: CloudUserInputItem[] = []; + let cursor = ""; + for (let page = 0; page < MAX_INDEX_PAGES; page++) { + const r = await mcTaskUserInputs(id, cursor); + all.push(...(r.items ?? [])); + if (!r.has_more || !r.next_cursor) break; + cursor = r.next_cursor; + } + if (alive) setRest(cloudOutlineItems(all)); + })().catch((e: unknown) => { + // 大纲缺席可接受(降级为只有实时条目),但失败必须留痕:上次命令没进 + // capabilities 白名单,invoke 被拒就是被这里的静默吞掉才难查的 + console.warn("[cloud-outline] 提问索引拉取失败:", e); + }); + return () => { + alive = false; + }; + }, [id]); + return useMemo(() => outlineEntries(mergeLiveOutline(rest, items)), [rest, items]); +} + +/** 大纲交互对视图的依赖面(CloudTaskHandle 的窄投影,避免反向依赖)。 */ +export interface CloudOutlineNavHost { + scrollRef: RefObject; + /** 把历史翻到某锚已加载(补页循环在 hook 内,游标经 ref 推进) */ + ensureLoaded(anchorSeq: number): Promise; + unpin(): void; +} + +/** 云端视图的大纲交互:当前项跟踪(滚动/帧批 rAF 节流重算,同 ChatView) + * 与跳转。目标气泡不在 DOM 时先 ensureLoaded 补齐历史(useCloudTask 内 + * 大步长翻页),再重试定位——重试吸收 React 提交延迟,与本地 jumpWithRetry + * 同款。 */ +export function useCloudOutlineNav(id: string, items: LogItem[], host: CloudOutlineNavHost) { + const entries = useCloudOutline(id, items); + const [activeSeq, setActiveSeq] = useState(undefined); + const raf = useRef(0); + // host 每次渲染都是新对象:跳转跨 await,经 ref 取最新 + const hostRef = useRef(host); + hostRef.current = host; + + const updateActive = () => { + const el = hostRef.current.scrollRef.current; + const col = el?.firstElementChild; + if (!el || !col) return; + const elTop = el.getBoundingClientRect().top; + const seq = outlineActiveSeq( + Array.from(col.children, (kid) => { + const raw = (kid as HTMLElement).dataset?.mcSeq; + return { top: kid.getBoundingClientRect().top, seq: raw ? Number(raw) : undefined }; + }), + elTop, + ); + setActiveSeq((prev) => (prev === seq ? prev : seq)); + }; + const scheduleActive = () => { + if (raf.current) return; + raf.current = window.requestAnimationFrame(() => { + raf.current = 0; + updateActive(); + }); + }; + useEffect(scheduleActive, [items]); + // 取消后必须把 id 清零:节流以「非零 = 已排队」判断,残留旧 id 会让它 + // 永远短路(StrictMode 双挂载即触发;与 ChatView 同一坑) + useEffect( + () => () => { + window.cancelAnimationFrame(raf.current); + raf.current = 0; + }, + [], + ); + + const jumpToSeq = (seq: number): boolean => { + const el = hostRef.current.scrollRef.current; + const col = el?.firstElementChild; + const node = col?.querySelector(`[data-mc-seq="${seq}"]`); + if (!el || !node) return false; + // 云端流为跟看场景:先解除贴底,否则下一批帧立刻拽回底部 + hostRef.current.unpin(); + el.scrollTop += node.getBoundingClientRect().top - el.getBoundingClientRect().top - OUTLINE_JUMP_INSET; + node.classList.remove("mc-jump-flash"); + void node.offsetWidth; // 重启动画 + node.classList.add("mc-jump-flash"); + window.setTimeout(() => node.classList.remove("mc-jump-flash"), 1000); + return true; + }; + + // 帧已在内存但 React 可能还没提交到 DOM:重试吸收提交延迟(同 ChatView) + const jumpWithRetry = (seq: number, tries = 12) => { + if (jumpToSeq(seq)) return; + if (tries <= 0) { + // 走到这只剩坏数据(无时间戳的旧帧对不上锚):留痕即可,不打扰用户 + console.warn("[cloud-outline] 跳转目标未定位到:", seq); + return; + } + window.setTimeout(() => jumpWithRetry(seq, tries - 1), 32); + }; + + const onJump = (e: OutlineEntry) => + void (async () => { + if (jumpToSeq(e.seq)) return; + await hostRef.current.ensureLoaded(e.seq); + jumpWithRetry(e.seq); + })(); + + return { entries, activeSeq, onJump, onScrollTick: scheduleActive }; +} diff --git a/desktop/ui/src/cloudapi.ts b/desktop/ui/src/cloudapi.ts index 1421faa2..fb733e08 100644 --- a/desktop/ui/src/cloudapi.ts +++ b/desktop/ui/src/cloudapi.ts @@ -4,7 +4,7 @@ import { b64encode, frameData } from "./codec"; import type { McTaskOptions } from "./cloud"; import { invoke, listenAsync } from "./ipc"; -import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, Frame, McModelsSyncResult, McStatus, McUsage, McUser, WsCloseInfo } from "./types"; +import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, CloudUserInputsResp, Frame, McModelsSyncResult, McStatus, McUsage, McUser, WsCloseInfo } from "./types"; // ==================== 云端 REST(壳命令代理) ==================== @@ -60,6 +60,10 @@ export const mcTaskRounds = (id: string, cursor = "", limit = 1) => limit, }); +/** 提问索引(倒序,cursor 向更早翻;大纲数据源,content 已解码明文)。 */ +export const mcTaskUserInputs = (id: string, cursor = "", limit = 100) => + invoke("mc_task_user_inputs", { id, cursor, limit }); + /** 终止云端任务(区别于流上行 user-cancel:那只中断当前执行)。 */ export const mcTaskStop = (id: string) => invoke<{ ok: boolean }>("mc_task_stop", { id }); diff --git a/desktop/ui/src/cloudtask.tsx b/desktop/ui/src/cloudtask.tsx index b610f31a..7a4703b2 100644 --- a/desktop/ui/src/cloudtask.tsx +++ b/desktop/ui/src/cloudtask.tsx @@ -11,9 +11,10 @@ import { CloudTerminal } from "./cloudterm"; import { MAX_CLOUD_ATTS } from "./cloudUpload"; import { COL_MAX, ModelPickerTrigger } from "./chat"; import { CloudModelGroups } from "./cloudModelMenu"; +import { useCloudOutlineNav } from "./cloudOutline"; import { CloudStartupCard } from "./cloudStartup"; import { SlashCommandMenu, useSlashCommands } from "./commandMenu"; -import { HeaderFilesButton, HeaderMenu, LogList, TaskPanel, ViewHeader, type MenuState } from "./components"; +import { HeaderFilesButton, HeaderMenu, LogList, OutlineNav, TaskPanel, ViewHeader, type MenuState } from "./components"; import { Composer, QueuedChip, RunningBar } from "./composer"; import { IconCloud, IconFile, IconGlobe, IconMonitor, IconPaperclip, IconStop, IconX } from "./icons"; import { useUpwardMenuHeight } from "./menuPosition"; @@ -106,6 +107,9 @@ export function CloudTaskView({ enabled: !ended, }); + // 提问大纲(REST 索引 + 实时用户消息;交互复用本地 OutlineNav) + const nav = useCloudOutlineNav(h.id, chat.items, h); + // 云端模型下拉开合(列表加载/切换在 hook) const [modelOpen, setModelOpen] = useState(false); const { anchorRef: modelAnchorRef, menuMaxHeight: modelMenuMaxHeight } = useUpwardMenuHeight(modelOpen, 320); @@ -276,7 +280,10 @@ export function CloudTaskView({
{ + h.onScroll(); + nav.onScrollTick(); + }} style={{ flex: 1, overflowY: "auto", overflowX: "hidden", minHeight: 0, scrollbarGutter: "stable both-edges" }} >
@@ -530,6 +537,9 @@ export function CloudTaskView({ )}
+ {/* ==== 提问大纲(与本地会话同款点列+浮窗;启动页没有对话流,不挂)==== */} + {taskStatus !== "pending" && } + {/* ==== 云端文件抽屉(共享 FilesDrawer 浮层,数据经控制流适配; 上传只对未结束任务开放——结束态 VM 已回收,写不进去)==== */} {filesOpen && setFilesOpen(false)} />} diff --git a/desktop/ui/src/components.tsx b/desktop/ui/src/components.tsx index c76dfdd7..127762a1 100644 --- a/desktop/ui/src/components.tsx +++ b/desktop/ui/src/components.tsx @@ -10,6 +10,7 @@ export { LogList } from "./logView"; export { OutlineNav, OUTLINE_JUMP_INSET, + mergeLiveOutline, outlineActiveSeq, outlineEntries, type OutlineEntry, diff --git a/desktop/ui/src/mcaccount.test.ts b/desktop/ui/src/mcaccount.test.ts index 171689db..761a6093 100644 --- a/desktop/ui/src/mcaccount.test.ts +++ b/desktop/ui/src/mcaccount.test.ts @@ -43,7 +43,7 @@ describe("inspectMcAccount", () => { expect(result.historicalTasks).toEqual([]); }); - it("快速任务和历史任务按时间倒序且最多展示五条", async () => { + it("快速任务倒序截 5 条,历史任务倒序全保留(不再切 5 条)", async () => { const source = Array.from({ length: 7 }, (_, index) => ({ id: `task-${index}`, status: "finished" as const, @@ -57,6 +57,8 @@ describe("inspectMcAccount", () => { ); expect(result.tasks?.map((task) => task.id)).toEqual(["task-6", "task-5", "task-4", "task-3", "task-2"]); - expect(result.historicalTasks?.map((task) => task.id)).toEqual(["task-6", "task-5", "task-4", "task-3", "task-2"]); + expect(result.historicalTasks?.map((task) => task.id)).toEqual([ + "task-6", "task-5", "task-4", "task-3", "task-2", "task-1", "task-0", + ]); }); }); diff --git a/desktop/ui/src/mcaccount.ts b/desktop/ui/src/mcaccount.ts index f0b817a8..8012c771 100644 --- a/desktop/ui/src/mcaccount.ts +++ b/desktop/ui/src/mcaccount.ts @@ -15,11 +15,14 @@ export interface McAccountSnapshot { taskError?: string; } -/** 与 Web 侧栏一致:快速任务和历史任务只保留最近 5 条。 */ -function recentTasks(response: CloudTasksResp): CloudTask[] { - return [...(response.tasks ?? [])] - .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0)) - .slice(0, 5); +/** 快速任务与 Web 侧栏一致只保留最近 5 条(顶层平铺,多了是噪音); + * 历史任务在可折叠分组里,一页(50 条)全展示——切 5 条会让更早的任务 + * 从桌面上彻底消失,连搜索都搜不到。 */ +function recentTasks(response: CloudTasksResp, cap?: number): CloudTask[] { + const sorted = [...(response.tasks ?? [])].sort( + (a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0), + ); + return cap === undefined ? sorted : sorted.slice(0, cap); } export async function inspectMcAccount( @@ -41,7 +44,7 @@ export async function inspectMcAccount( .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason)); return { status, - ...(active.status === "fulfilled" ? { tasks: recentTasks(active.value) } : {}), + ...(active.status === "fulfilled" ? { tasks: recentTasks(active.value, 5) } : {}), ...(historical.status === "fulfilled" ? { historicalTasks: recentTasks(historical.value) } : {}), ...(projects.status === "fulfilled" ? { projects: projects.value.projects ?? [] } : {}), ...(errors.length ? { taskError: errors.join(";") } : {}), diff --git a/desktop/ui/src/outline.test.tsx b/desktop/ui/src/outline.test.tsx index d590885b..f441c208 100644 --- a/desktop/ui/src/outline.test.tsx +++ b/desktop/ui/src/outline.test.tsx @@ -3,7 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { OutlineNav, OUTLINE_JUMP_INSET, outlineActiveSeq, outlineEntries } from "./components"; +import { OutlineNav, OUTLINE_JUMP_INSET, mergeLiveOutline, outlineActiveSeq, outlineEntries } from "./components"; +import type { LogItem } from "./types"; import type { OutlineItem } from "./useSession"; const item = (over: Partial = {}): OutlineItem => ({ @@ -56,6 +57,42 @@ describe("outlineEntries", () => { }); }); +describe("mergeLiveOutline", () => { + const user = (seq: number, text: string, timestamp?: number): LogItem => ({ + kind: "user", + text, + seq, + ...(timestamp !== undefined ? { timestamp } : {}), + }); + + it("刚发出的提问(目录里还没有)从对话流补进大纲尾部", () => { + const merged = mergeLiveOutline([item({ seq: 1, text: "第一问" })], [ + user(1, "第一问"), + { kind: "agent", text: "回答" }, + user(9, "最新一问", 1722_000_000_000), + ]); + expect(merged.map((it) => it.seq)).toEqual([1, 9]); + expect(merged[1]).toEqual({ seq: 9, offset: 0, text: "最新一问", timestamp: 1722_000_000_000 }); + }); + + it("目录已有的条目以目录为准(带真实翻页偏移),不重复", () => { + const merged = mergeLiveOutline([item({ seq: 1, offset: 1024 })], [user(1, "第一问")]); + expect(merged).toHaveLength(1); + expect(merged[0].offset).toBe(1024); + }); + + it("无 seq 的用户条目(旧记录)与非用户条目都不进大纲", () => { + const noSeq: LogItem = { kind: "user", text: "旧记录" }; + const merged = mergeLiveOutline([], [noSeq, { kind: "sys", text: "— 本轮结束 —" }]); + expect(merged).toEqual([]); + }); + + it("没有新增时原样返回目录(引用不变,memo 不空转)", () => { + const items = [item()]; + expect(mergeLiveOutline(items, [user(1, "第一问")])).toBe(items); + }); +}); + describe("outlineActiveSeq", () => { it("跳转目标落在顶部留白线时标记目标,而不是上一问", () => { expect( diff --git a/desktop/ui/src/outline.tsx b/desktop/ui/src/outline.tsx index 768dcfe2..ca193dc5 100644 --- a/desktop/ui/src/outline.tsx +++ b/desktop/ui/src/outline.tsx @@ -6,6 +6,7 @@ // 点本身不响应点击:6px 的目标太小,误点代价是整屏跳走。 import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ATT_LINE } from "./logView"; +import type { LogItem } from "./types"; import type { OutlineItem } from "./useSession"; export interface OutlineEntry { @@ -36,6 +37,24 @@ export function outlineActiveSeq( return seq; } +/** 大纲目录 + 对话流里的实时用户消息 → 合并视图。 + * + * 目录(壳的 session_outline / 云端 user-inputs 索引)是磁盘/服务端数据, + * 刚发出的提问要等落盘/轮末物化才进得去——帧落盘还是异步写线程,回显到达 + * 时读盘未必看得见。最新一条只能从已归约的对话流里拿:凡带 seq 的用户 + * 条目且目录里没有的,按流内顺序补到尾部(它们必然是最新的几条,天然有序)。 + * 同 seq 以目录为准:目录条目带真实翻页偏移,流内补的只有 0。 */ +export function mergeLiveOutline(items: OutlineItem[], logItems: LogItem[]): OutlineItem[] { + const seen = new Set(items.map((it) => it.seq)); + const tail: OutlineItem[] = []; + for (const it of logItems) { + if (it.kind !== "user" || it.seq === undefined || seen.has(it.seq)) continue; + seen.add(it.seq); + tail.push({ seq: it.seq, offset: 0, text: it.text, ...(it.timestamp !== undefined ? { timestamp: it.timestamp } : {}) }); + } + return tail.length ? [...items, ...tail] : items; +} + function hhmm(ts?: number): string { if (ts === undefined || !Number.isFinite(ts)) return ""; const d = new Date(ts); diff --git a/desktop/ui/src/types.ts b/desktop/ui/src/types.ts index 0801ea89..380f0aa0 100644 --- a/desktop/ui/src/types.ts +++ b/desktop/ui/src/types.ts @@ -499,6 +499,23 @@ export interface CloudTasksResp { page_info?: { total?: number; total_count?: number }; } +/** 云端任务提问索引条目(GET users/tasks/user-inputs;大纲数据源)。 + * content 已是解码明文(超 500 字符截断);timestamp 纳秒,与 chunk.timestamp + * 对齐——大纲靠时间戳与帧流对表(seq 仅 ClickHouse 存储有,不可依赖)。 */ +export interface CloudUserInputItem { + id?: string; + content?: string; + timestamp?: number; + seq?: number; + truncated?: boolean; +} + +export interface CloudUserInputsResp { + items?: CloudUserInputItem[]; + next_cursor?: string; + has_more?: boolean; +} + /** 云端项目;列表接口与 Web 侧栏一致,会附带项目下的最近任务。 */ export interface CloudProject { id?: string; diff --git a/desktop/ui/src/useCloudTask.ts b/desktop/ui/src/useCloudTask.ts index 53232896..9811d4f9 100644 --- a/desktop/ui/src/useCloudTask.ts +++ b/desktop/ui/src/useCloudTask.ts @@ -27,6 +27,7 @@ import { type CloudUserInput, } from "./cloudapi"; import { groupCloudModels, type McCloudModelGroup } from "./cloud"; +import { framesHaveAnchor, withCloudOutlineAnchors } from "./cloudOutline"; import { MAX_CLOUD_ATTS, uploadCloudFile, type CloudUploadedAtt } from "./cloudUpload"; import { frameData } from "./codec"; import { answerAsk as applyAskAnswer, initialChat, reduceBatch, type ChatState } from "./reduce"; @@ -450,6 +451,8 @@ export interface CloudTaskHandle { cursor: { cursor: string; hasMore: boolean } | null; loadingEarlier: boolean; loadEarlier(): Promise; + /** 把历史翻到某大纲锚已加载(大纲跳转用;返回是否找到)。 */ + ensureLoaded(anchorSeq: number): Promise; /** 云端可用模型分组(null = 未加载/拉取失败可重试;loadModels 惰性拉取) */ cloudGroups: McCloudModelGroup[] | null; switching: boolean; @@ -462,6 +465,8 @@ export interface CloudTaskHandle { scrollRef: RefObject; onWheel(e: { deltaY: number }): void; onScroll(): void; + /** 解除贴底跟随(大纲跳转等程序滚动前调用,否则下一批帧又拽回底部) */ + unpin(): void; } export function useCloudTask( @@ -480,7 +485,16 @@ export function useCloudTask( const [connected, setConnected] = useState(false); const [input, setInput] = useState(""); const [cursor, setCursor] = useState<{ cursor: string; hasMore: boolean } | null>(null); + // 游标的权威读写走 ref,state 只是渲染镜像:大纲跳转的补页循环跨多次 + // await,读 state 闭包会拿到旧游标(重复拉同一页/误判"无进展"提前放弃) + const cursorRef = useRef<{ cursor: string; hasMore: boolean } | null>(null); + const applyCursor = (c: { cursor: string; hasMore: boolean } | null) => { + cursorRef.current = c; + setCursor(c); + }; const [loadingEarlier, setLoadingEarlier] = useState(false); + // 翻页互斥同理走 ref:state 闭包在连续 await 间是陈旧的 + const loadingEarlierRef = useRef(false); const [err, setErr] = useState(""); const [queued, setQueuedState] = useState(""); const [queuedAtts, setQueuedAttsState] = useState([]); @@ -508,12 +522,16 @@ export function useCloudTask( const coreRef = useRef(null); if (!coreRef.current) { const io: CloudCoreIO = { - applyFrames: (frames) => setChat((s) => reduceBatch(s, frames)), - rebuildChat: (frames) => setChat(reduceBatch(initialChat, frames)), + // 归约边界统一给 user-input 帧盖大纲锚(seq=时间戳锚);core 存原始帧, + // WS 去重水位与回放缓存不受影响 + applyFrames: (frames) => setChat((s) => reduceBatch(s, withCloudOutlineAnchors(frames))), + rebuildChat: (frames) => setChat(reduceBatch(initialChat, withCloudOutlineAnchors(frames))), applyAskAnswer: (askId, answers) => setChat((s) => applyAskAnswer(s, askId, answers)), setStatus, setConnected, - setCursorIfEmpty: (c, hasMore) => setCursor((prev) => prev ?? { cursor: c, hasMore }), + setCursorIfEmpty: (c, hasMore) => { + if (!cursorRef.current) applyCursor({ cursor: c, hasMore }); + }, setQueued: (text, queueAtts) => { setQueuedState(text); setQueuedAttsState(queueAtts); @@ -539,7 +557,7 @@ export function useCloudTask( const label = task.title || task.summary || task.content || meta?.title || meta?.summary || "云端任务"; const rebuild = useCallback(() => { - setChat(reduceBatch(initialChat, core.frames())); + setChat(reduceBatch(initialChat, withCloudOutlineAnchors(core.frames()))); }, [core]); const refreshInfo = useCallback(async () => { @@ -560,7 +578,7 @@ export function useCloudTask( useEffect(() => { core.resetForTask(); setChat(initialChat); - setCursor(null); + applyCursor(null); setErr(""); setInput(""); setAtts([]); @@ -575,7 +593,7 @@ export function useCloudTask( const r = await mcTaskRounds(id, "", 1); if (!alive) return; core.seedHistory(r.frames ?? []); - setCursor(r.next_cursor ? { cursor: r.next_cursor, hasMore: !!r.has_more } : null); + applyCursor(r.next_cursor ? { cursor: r.next_cursor, hasMore: !!r.has_more } : null); rebuild(); setStatus("已结束,只读回放"); } catch (e) { @@ -664,22 +682,58 @@ export function useCloudTask( if (el && pinnedRef.current) el.scrollTop = el.scrollHeight; }, [chat]); + /** 往前翻并推进游标(权威游标在 ref,连续调用不受渲染时序影响)。 */ + const fetchEarlier = async (limit: number) => { + const cur = cursorRef.current; + if (!cur) return; + const r = await mcTaskRounds(id, cur.cursor, limit); + core.prependHistory(r.frames ?? []); + applyCursor(r.next_cursor && r.has_more !== false ? { cursor: r.next_cursor, hasMore: !!r.has_more } : null); + pinnedRef.current = false; + rebuild(); + }; + const loadEarlier = async () => { - if (!cursor || loadingEarlier) return; + if (!cursorRef.current || loadingEarlierRef.current) return; + loadingEarlierRef.current = true; setLoadingEarlier(true); try { - const r = await mcTaskRounds(id, cursor.cursor, 1); - core.prependHistory(r.frames ?? []); - setCursor(r.next_cursor && r.has_more !== false ? { cursor: r.next_cursor, hasMore: !!r.has_more } : null); - pinnedRef.current = false; - rebuild(); + await fetchEarlier(1); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { + loadingEarlierRef.current = false; setLoadingEarlier(false); } }; + /** 大纲跳转:把历史一直往前翻到该锚的 user-input 帧已加载(或翻完/失败)。 + * 判定读 core 的帧集而非 DOM——prepend 后帧立即可查,不赌 React 提交时序; + * 大步长(一次 10 轮,壳上限)减少跳到很早提问时的串行往返。返回是否找到。 */ + const ensureLoaded = async (anchorSeq: number): Promise => { + const found = () => framesHaveAnchor(core.frames(), anchorSeq); + if (found()) return true; + // 手动"加载更早"在途时稍候(它很快),拿不到独占就放弃本次预加载 + for (let i = 0; i < 30 && loadingEarlierRef.current; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (loadingEarlierRef.current) return found(); + loadingEarlierRef.current = true; + setLoadingEarlier(true); + try { + // 200 × 10 轮的护栏:防坏游标空转;正常任务远在其内 + for (let i = 0; i < 200 && cursorRef.current && !found(); i++) { + await fetchEarlier(10); + } + } catch (e) { + setErr("加载更早的对话失败: " + (e instanceof Error ? e.message : String(e))); + } finally { + loadingEarlierRef.current = false; + setLoadingEarlier(false); + } + return found(); + }; + const send = () => { // 发原文,不 trim:云端按 `/name args` 解析斜杠指令,指令名后那个空格是 // 分隔符——抹掉它,技能就只是一句普通文本(Web 侧同样原样发,见 @@ -840,6 +894,7 @@ export function useCloudTask( cursor, loadingEarlier, loadEarlier, + ensureLoaded, cloudGroups, switching, loadModels, @@ -854,5 +909,8 @@ export function useCloudTask( const el = scrollRef.current; if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 40) pinnedRef.current = true; }, + unpin: () => { + pinnedRef.current = false; + }, }; }