Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/baizhi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
limit: Option<u32>,
) -> Result<Value, String> {
// 后端上限 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<Value, String> {
monkeycode::mc_task_stop(&bz.0, &id).await.map_err(BzErr::msg)?;
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/baizhi/monkeycode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> {
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 })))
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/baizhi/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {} }))
}
Expand All @@ -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();

Expand All @@ -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"));
}
Expand Down
1 change: 1 addition & 0 deletions desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion desktop/ui/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
OUTLINE_JUMP_INSET,
TaskPanel,
ViewHeader,
mergeLiveOutline,
outlineActiveSeq,
outlineEntries,
useRenameDraft,
Expand Down Expand Up @@ -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<number | undefined>(undefined);
const activeRaf = useRef(0);
// 当前视口所在的提问 = 视口顶部之上最后一条用户气泡。判定沿用 saveAnchor
Expand Down
94 changes: 94 additions & 0 deletions desktop/ui/src/cloudOutline.test.ts
Original file line number Diff line number Diff line change
@@ -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(["第一问", "最新一问"]);
});
});
181 changes: 181 additions & 0 deletions desktop/ui/src/cloudOutline.ts
Original file line number Diff line number Diff line change
@@ -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<OutlineItem[]>([]);
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<HTMLDivElement | null>;
/** 把历史翻到某锚已加载(补页循环在 hook 内,游标经 ref 推进) */
ensureLoaded(anchorSeq: number): Promise<boolean>;
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<number | undefined>(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<HTMLElement>(`[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 };
}
Loading
Loading