diff --git a/web/node_modules b/web/node_modules
new file mode 120000
index 0000000..9cff655
--- /dev/null
+++ b/web/node_modules
@@ -0,0 +1 @@
+/Users/qingse/Sync/local-git/oh-my-muse/agent-example/web/node_modules
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index eab8bce..9f6fe1b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,8 +10,9 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
- "lint": "eslint src",
- "typecheck": "tsc -b --noEmit"
+ "lint": "eslint src tests",
+ "typecheck": "tsc -b --noEmit",
+ "test:journey": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.102.8",
@@ -28,7 +29,6 @@
"@eslint/js": "9.15.0",
"@playwright/test": "^1.49.0",
"@testing-library/react": "^16.3.3",
- "@testing-library/user-event": "^14.6.7",
"@types/node": "22.20.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -37,7 +37,6 @@
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^26.1.0",
"openapi-typescript": "^7.13.0",
- "prettier": "^3.9.6",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.1",
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index e838747..0cf2422 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -1,4 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
+import { 核对旅程环境 } from "./旅程环境";
+
+if (!process.argv.includes("--list")) 核对旅程环境(process.env);
// 真实浏览器旅程:对真后端与隔离库执行中文输入与实际点击。
// 用例登记在 web/tests/旅程/,覆盖环境合同与端到端交互。
@@ -7,10 +10,10 @@ export default defineConfig({
timeout: 60_000,
retries: 0,
use: {
- baseURL: process.env.MUSE_WORKBENCH_URL || "http://127.0.0.1:5173",
+ baseURL: process.env.MUSE_WORKBENCH_URL,
locale: "zh-CN",
trace: "retain-on-failure",
- launchOptions: {executablePath: process.env.MUSE_BROWSER_EXECUTABLE},
+ launchOptions: { executablePath: process.env.MUSE_BROWSER_EXECUTABLE },
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 1c47476..d0c4e7a 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -45,9 +45,6 @@ importers:
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@testing-library/user-event':
- specifier: ^14.6.7
- version: 14.6.7(@testing-library/dom@10.4.1)
'@types/node':
specifier: 22.20.1
version: 22.20.1
@@ -72,9 +69,6 @@ importers:
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
- prettier:
- specifier: ^3.9.6
- version: 3.9.6
typescript:
specifier: ^5.6.3
version: 5.9.3
@@ -765,12 +759,6 @@ packages:
'@types/react-dom':
optional: true
- '@testing-library/user-event@14.6.7':
- resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
- engines: {node: '>=12', npm: '>=6'}
- peerDependencies:
- '@testing-library/dom': '>=7.21.4'
-
'@tiptap/core@3.31.3':
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
peerDependencies:
@@ -1606,11 +1594,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
- prettier@3.9.6:
- resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
- engines: {node: '>=14'}
- hasBin: true
-
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -2550,10 +2533,6 @@ snapshots:
'@types/react': 18.3.31
'@types/react-dom': 18.3.7(@types/react@18.3.31)
- '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)':
- dependencies:
- '@testing-library/dom': 10.4.1
-
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/pm': 3.31.3
@@ -3465,8 +3444,6 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier@3.9.6: {}
-
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
diff --git a/web/src/功能/上下文/上下文清单.tsx b/web/src/功能/上下文/上下文清单.tsx
index 2278654..4471d7b 100644
--- a/web/src/功能/上下文/上下文清单.tsx
+++ b/web/src/功能/上下文/上下文清单.tsx
@@ -45,6 +45,15 @@ export function Ui任务上下文({
任务实际消费的材料会在冻结后出现在这里,并标注每处来源的版本。
)}
+ {query.hasNextPage && (
+
+ )}
+ 每页20份,最多保留最近载入的3页。
);
}
diff --git a/web/src/功能/上下文/数据.ts b/web/src/功能/上下文/数据.ts
index c43dbe2..367695a 100644
--- a/web/src/功能/上下文/数据.ts
+++ b/web/src/功能/上下文/数据.ts
@@ -1,7 +1,7 @@
/** 上下文功能的数据接入:任务材料快照的查询与稳定标签。 */
-import { useQuery } from "@tanstack/react-query";
+import { useInfiniteQuery } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
-import { 查询键 } from "../../接口/查询键";
+import { 任务查询键 } from "../../接口/查询键";
import type { operations } from "../../接口/生成/类型";
export type 任务上下文快照 =
@@ -9,16 +9,33 @@ export type 任务上下文快照 =
/** 快照键挂在任务键之下:任务事件失效任务查询时一并刷新材料。 */
export function useMuse任务上下文(scope: string | null, taskId: string) {
- return useQuery({
- queryKey: 查询键(scope, taskId, "context-snapshots"),
+ const query = useInfiniteQuery({
+ queryKey: 任务查询键(scope, taskId, "context-snapshots"),
enabled: !!taskId,
- queryFn: async ({ signal }) =>
- (
- await 客户端.GET("/api/v1/tasks/{task_id}/context-snapshots", {
- params: { path: { task_id: taskId }, signal },
- })
- ).data!,
+ initialPageParam: null as string | null,
+ maxPages: 3,
+ queryFn: async ({ signal, pageParam }) => {
+ const { data } = await 客户端.GET(
+ "/api/v1/tasks/{task_id}/context-snapshots/page",
+ {
+ params: {
+ path: { task_id: taskId },
+ query: {
+ work_id: scope ?? undefined,
+ limit: 20,
+ cursor: pageParam ?? undefined,
+ },
+ },
+ signal,
+ },
+ );
+ if (!data) throw new Error("材料快照分页未返回。");
+ return data;
+ },
+ getNextPageParam: (page) =>
+ page.has_more ? (page.next_cursor ?? undefined) : undefined,
});
+ return { ...query, data: query.data?.pages.flatMap((page) => page.items) };
}
export const 快照状态: Record = {
diff --git a/web/src/功能/任务运行/任务列表.tsx b/web/src/功能/任务运行/任务列表.tsx
index d8328d4..66fb13d 100644
--- a/web/src/功能/任务运行/任务列表.tsx
+++ b/web/src/功能/任务运行/任务列表.tsx
@@ -4,8 +4,8 @@ import { useSearchParams } from "react-router-dom";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作品会话 } from "../../应用/作品会话";
-import { 查询键 } from "../../接口/查询键";
-import { 状态 } from "./数据";
+import { 任务查询键, 任务列表键 } from "../../接口/查询键";
+import { 状态, 读取任务 } from "./数据";
import { Ui任务详情 } from "./任务详情";
import { Ui发起生成 } from "../创作对话/会话面板";
@@ -13,7 +13,7 @@ export function Ui任务列表() {
const [params] = useSearchParams();
const work = useMuse作品会话();
const allTasks = useQuery({
- queryKey: ["任务总览"],
+ queryKey: 任务列表键(null),
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/tasks", { signal })).data!,
});
@@ -25,7 +25,7 @@ export function Ui任务列表() {
),
];
const list = useQuery({
- queryKey: 查询键(work.workId, "任务列表"),
+ queryKey: 任务列表键(work.workId),
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks", {
@@ -37,18 +37,9 @@ export function Ui任务列表() {
const [input, setInput] = useState(params.get("task") ?? "");
const [taskId, setTaskId] = useState(params.get("task") ?? "");
const task = useQuery({
- queryKey: 查询键(work.workId, taskId),
+ queryKey: 任务查询键(work.workId, taskId),
enabled: !!taskId,
- queryFn: async ({ signal }) =>
- (
- await 客户端.GET("/api/v1/tasks/{task_id}", {
- params: {
- path: { task_id: taskId },
- query: { work_id: work.workId ?? undefined },
- },
- signal,
- })
- ).data!,
+ queryFn: ({ signal }) => 读取任务(taskId, work.workId, signal),
});
function find(event: FormEvent) {
event.preventDefault();
diff --git a/web/src/功能/任务运行/任务订阅.ts b/web/src/功能/任务运行/任务订阅.ts
new file mode 100644
index 0000000..fe5ae1d
--- /dev/null
+++ b/web/src/功能/任务运行/任务订阅.ts
@@ -0,0 +1,183 @@
+import { useEffect, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { 客户端 } from "../../接口/生成/客户端";
+import { 订阅任务事件 } from "../../接口/事件连接";
+import { 任务查询键, 任务查询根 } from "../../接口/查询键";
+import { 读取任务, type 任务, type 任务事件 } from "./数据";
+
+const terminal = (task: 任务) =>
+ (task.state === "completed" || task.state === "cancelled") &&
+ task.execution_final_sequence !== null &&
+ task.execution_final_sequence !== undefined;
+
+/** 每个挂载作用域最多一条执行流和一个快照请求;费用是独立的显式读回。 */
+export function useMuse任务订阅(task: 任务, scope: string | null) {
+ const cache = useQueryClient();
+ const [events, setEvents] = useState<任务事件[]>([]),
+ [notice, setNotice] = useState("");
+ useEffect(() => {
+ let alive = true,
+ recovering = false,
+ complete = terminal(task),
+ requiredSequence = task.last_sequence;
+ let close = () => {},
+ timer: ReturnType | undefined,
+ inFlight: Promise<任务> | undefined;
+ const historyAbort = new AbortController();
+ const append = (event: 任务事件) => {
+ // 只保留工作面显示需要的近期增量,不把任意大payload积存在事件面板。
+ const item =
+ event.event_type === "text.delta"
+ ? {
+ ...event,
+ payload: { 文本: String(event.payload.文本 ?? "").slice(-4096) },
+ }
+ : event;
+ setEvents((old) =>
+ old.some((value) => value.sequence === item.sequence)
+ ? old
+ : [...old, item].sort((a, b) => a.sequence - b.sequence).slice(-40),
+ );
+ };
+ async function history(snapshot: 任务) {
+ const { data } = await 客户端.GET("/api/v1/tasks/{task_id}/events", {
+ params: {
+ path: { task_id: snapshot.task_id },
+ query: {
+ cursor: Math.max(0, snapshot.last_sequence - 40),
+ limit: 40,
+ work_id: scope ?? undefined,
+ },
+ },
+ signal: historyAbort.signal,
+ });
+ if (!alive || !data) return;
+ if (data.reset_required) {
+ setNotice("较早事件已归档,当前任务与结果已从快照恢复。");
+ return;
+ }
+ data.items
+ .filter((event) => event.sequence <= snapshot.last_sequence)
+ .forEach(append);
+ }
+ function refresh() {
+ if (inFlight) return inFlight;
+ inFlight = cache.fetchQuery({
+ queryKey: 任务查询键(scope, task.task_id),
+ queryFn: ({ signal }) => 读取任务(task.task_id, scope, signal),
+ staleTime: 0,
+ });
+ const request = inFlight;
+ void request
+ .finally(() => {
+ if (inFlight === request) inFlight = undefined;
+ })
+ .catch(() => {});
+ return request;
+ }
+ async function refreshRelated(final = false) {
+ if (final && inFlight) await inFlight.catch(() => {});
+ const snapshot = await refresh();
+ if (!alive) return;
+ await Promise.all(
+ ["context-snapshots", "运行观察"].map((section) =>
+ cache.invalidateQueries({
+ queryKey: 任务查询键(scope, task.task_id, section),
+ exact: true,
+ }),
+ ),
+ );
+ if (!complete && snapshot.last_sequence < requiredSequence) schedule();
+ }
+ function schedule() {
+ if (timer || !alive || recovering || complete) return;
+ timer = setTimeout(() => {
+ timer = undefined;
+ void refreshRelated().catch(() => {
+ if (alive) setNotice("进度读取暂未完成,可重新读取任务。");
+ });
+ }, 100);
+ }
+ function subscribe(snapshot: 任务) {
+ if (!alive) return;
+ if (terminal(snapshot)) {
+ complete = true;
+ return;
+ }
+ close = 订阅任务事件(
+ task.task_id,
+ {
+ 收到(event) {
+ if (!alive) return;
+ append(event);
+ setNotice("");
+ requiredSequence = Math.max(requiredSequence, event.sequence);
+ if (event.event_type !== "text.delta") schedule();
+ },
+ 缺口() {
+ if (!alive || recovering) return;
+ recovering = true;
+ if (timer) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ setNotice("事件有缺口,正在从任务快照恢复。");
+ const restore = async () => {
+ if (inFlight) await inFlight.catch(() => {});
+ return refresh();
+ };
+ void restore()
+ .then((snapshot) => {
+ if (!alive) return;
+ recovering = false;
+ requiredSequence = snapshot.last_sequence;
+ setEvents([]);
+ setNotice("");
+ void history(snapshot).catch(() => {
+ if (alive)
+ setNotice("近期事件暂时无法读取,任务快照已恢复。");
+ });
+ subscribe(snapshot);
+ })
+ .catch(() => {
+ if (alive) {
+ recovering = false;
+ setNotice("任务快照恢复失败,请重新打开此任务。");
+ }
+ });
+ },
+ 重连() {
+ if (alive) setNotice("连接正在恢复,任务会继续保留。");
+ },
+ 完成() {
+ complete = true;
+ if (timer) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ setNotice("执行记录已完整接收。");
+ void refreshRelated(true).catch(() => {
+ if (alive) setNotice("执行记录已收尾,最终快照暂未读回。");
+ });
+ },
+ },
+ { cursor: snapshot.last_sequence, workId: scope },
+ );
+ }
+ setEvents([]);
+ void history(task).catch(() => {
+ if (alive) setNotice("近期事件暂时无法读取。");
+ });
+ subscribe(task);
+ return () => {
+ alive = false;
+ close();
+ historyAbort.abort();
+ if (timer) clearTimeout(timer);
+ void cache.cancelQueries({ queryKey: 任务查询根(scope, task.task_id) });
+ };
+ // task是挂载时的权威快照,后续刷新不能重建仍活跃的订阅。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cache, task.task_id, scope]);
+ return { events, notice };
+}
diff --git a/web/src/功能/任务运行/任务详情.tsx b/web/src/功能/任务运行/任务详情.tsx
index ec59861..0459b95 100644
--- a/web/src/功能/任务运行/任务详情.tsx
+++ b/web/src/功能/任务运行/任务详情.tsx
@@ -1,5 +1,4 @@
-import { useEffect, useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
import { Ui任务上下文 } from "../上下文";
import { Ui消息流 } from "../创作对话/消息流";
import { Ui作者动作 } from "../创作对话/作者动作";
@@ -7,10 +6,10 @@ import { useMuse作者会话 } from "../../应用/作者会话";
import { Ui恢复控制 } from "./恢复控制";
import { Ui任务预算 } from "./任务预算";
import { Ui运行观察 } from "../作者经验/运行观察";
-import { 订阅任务事件 } from "../../接口/事件连接";
-import { 查询键 } from "../../接口/查询键";
+import { useMuse任务订阅 } from "./任务订阅";
+import { Ui调用账目 } from "./调用账目";
import { Ui反馈状态 } from "../../界面/反馈状态";
-import { 状态, 事件名, type 任务, type 任务事件 } from "./数据";
+import { 状态, 事件名, type 任务 } from "./数据";
export function Ui任务详情({
task,
@@ -19,32 +18,18 @@ export function Ui任务详情({
task: 任务;
scope: string | null;
}) {
- const cache = useQueryClient();
- const [events, setEvents] = useState<任务事件[]>([]);
- const [notice, setNotice] = useState("");
+ const [openedCandidate, setOpenedCandidate] = useState();
+ const { events, notice } = useMuse任务订阅(task, scope);
const 生成中 = task.task_type === "生成正文";
const 候选事件 = [...events]
.reverse()
.find((条) => 条.event_type === "candidate.saved");
+ const eventCandidate = 候选事件?.payload.candidate_id;
const 候选ID = 生成中
- ? ((候选事件?.payload as { candidate_id?: string } | undefined)
- ?.candidate_id ?? null)
+ ? (task.result_refs?.find((ref) => ref.kind === "writing_candidate")
+ ?.candidate_id ??
+ (typeof eventCandidate === "string" ? eventCandidate : null))
: null;
- useEffect(
- () =>
- 订阅任务事件(task.task_id, {
- 收到: (事件) => {
- setEvents((旧) => [...旧, 事件].slice(-40));
- setNotice("");
- void cache.invalidateQueries({
- queryKey: 查询键(scope, task.task_id),
- });
- },
- 缺口: () => setNotice("较早的事件已归档,请重新读取任务进度。"),
- 重连: () => setNotice("连接正在恢复,任务会继续保留。"),
- }),
- [task.task_id, scope, cache],
- );
return (
@@ -53,7 +38,8 @@ export function Ui任务详情({
{状态[task.state]}
{task.task_id}
-
+
+
{task.supports_step_advance && }
{task.steps.map((step, index) => (
@@ -72,8 +58,13 @@ export function Ui任务详情({
过程记录
- 实时更新
+
+ {task.execution_final_sequence != null ? "执行已收尾" : "实时更新"}
+
+
+ 显示最近40条过程记录;正文增量仅作有限预览,完整结果请打开候选。
+
{notice && }
{events.map((event) => (
@@ -107,7 +98,18 @@ export function Ui任务详情({
生成过程与候选检查
- {候选ID && }
+ {候选ID && openedCandidate !== 候选ID && (
+
+ )}
+ {openedCandidate && (
+
+ )}
)}
diff --git a/web/src/功能/任务运行/恢复控制.tsx b/web/src/功能/任务运行/恢复控制.tsx
index e4bf77c..58f9d90 100644
--- a/web/src/功能/任务运行/恢复控制.tsx
+++ b/web/src/功能/任务运行/恢复控制.tsx
@@ -2,9 +2,16 @@ import { useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
+import { 任务查询根, 任务列表键 } from "../../接口/查询键";
import type { 任务 } from "./数据";
-export function Ui恢复控制({ task }: { task: 任务 }) {
+export function Ui恢复控制({
+ task,
+ scope,
+}: {
+ task: 任务;
+ scope: string | null;
+}) {
const cache = useQueryClient();
const [busy, setBusy] = useState(false);
const [controlError, setControlError] = useState("");
@@ -19,7 +26,13 @@ export function Ui恢复控制({ task }: { task: 任务 }) {
await 客户端.POST("/api/v1/tasks/{task_id}/advance", {
params: { path: { task_id: task.task_id } },
});
- await cache.invalidateQueries();
+ await Promise.all([
+ cache.invalidateQueries({ queryKey: 任务查询根(scope, task.task_id) }),
+ cache.invalidateQueries({ queryKey: 任务列表键(scope) }),
+ ...(scope
+ ? [cache.invalidateQueries({ queryKey: 任务列表键(null) })]
+ : []),
+ ]);
} catch (e) {
setControlError(
e instanceof Error ? e.message : "该步骤未完成,请查看任务状态。",
@@ -44,7 +57,13 @@ export function Ui恢复控制({ task }: { task: 任务 }) {
},
});
pending.current = null;
- await cache.invalidateQueries();
+ await Promise.all([
+ cache.invalidateQueries({ queryKey: 任务查询根(scope, task.task_id) }),
+ cache.invalidateQueries({ queryKey: 任务列表键(scope) }),
+ ...(scope
+ ? [cache.invalidateQueries({ queryKey: 任务列表键(null) })]
+ : []),
+ ]);
} catch (e) {
setControlError(e instanceof Error ? e.message : "控制请求未完成。");
} finally {
diff --git a/web/src/功能/任务运行/数据.ts b/web/src/功能/任务运行/数据.ts
index 7d9d616..689f697 100644
--- a/web/src/功能/任务运行/数据.ts
+++ b/web/src/功能/任务运行/数据.ts
@@ -1,3 +1,4 @@
+import { 客户端 } from "../../接口/生成/客户端";
import type { operations } from "../../接口/生成/类型";
export type 任务 =
@@ -27,3 +28,19 @@ export const 事件名: Record = {
"step.checkpoint": "进度已保存",
"call.unknown": "等待结果核对",
};
+
+export async function 读取任务(
+ taskId: string,
+ work: string | null,
+ signal?: AbortSignal,
+) {
+ const { data } = await 客户端.GET("/api/v1/tasks/{task_id}", {
+ params: {
+ path: { task_id: taskId },
+ query: { work_id: work ?? undefined },
+ },
+ signal,
+ });
+ if (!data) throw new Error("任务快照未返回。");
+ return data;
+}
diff --git a/web/src/功能/任务运行/调用账目.tsx b/web/src/功能/任务运行/调用账目.tsx
new file mode 100644
index 0000000..b388da0
--- /dev/null
+++ b/web/src/功能/任务运行/调用账目.tsx
@@ -0,0 +1,346 @@
+import { useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useMuse作者会话 } from "../../应用/作者会话";
+import { 客户端 } from "../../接口/生成/客户端";
+import type { operations } from "../../接口/生成/类型";
+import { 接口错误 } from "../../接口/传输";
+import { 任务查询键 } from "../../接口/查询键";
+import type { 任务 } from "./数据";
+
+type 账目 =
+ operations["read_task_calls"]["responses"][200]["content"]["application/json"];
+type 费用请求 =
+ operations["settle_task_call"]["requestBody"]["content"]["application/json"];
+type 核对请求 =
+ operations["reconcile_task_call"]["requestBody"]["content"]["application/json"];
+type 待确认 =
+ | { kind: "settlement"; body: 费用请求 }
+ | { kind: "reconciliation"; body: 核对请求 };
+
+const object = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null;
+function 有效待确认(value: unknown): value is 待确认 {
+ if (
+ !object(value) ||
+ !object(value.body) ||
+ typeof value.body.command_id !== "string" ||
+ !value.body.command_id
+ )
+ return false;
+ const body = value.body;
+ if (value.kind === "reconciliation")
+ return typeof body.evidence_id === "string" && !!body.evidence_id;
+ return (
+ value.kind === "settlement" &&
+ Number.isInteger(body.expected_revision) &&
+ (typeof body.amount === "string" || typeof body.amount === "number") &&
+ body.currency === "USD" &&
+ typeof body.source_reference === "string" &&
+ typeof body.source_statement === "string" &&
+ typeof body.source_hash === "string" &&
+ /^[0-9a-f]{64}$/.test(body.source_hash) &&
+ body.source_kind === "author_attestation" &&
+ typeof body.zero_cost_confirmed === "boolean"
+ );
+}
+
+export function Ui调用账目({
+ task,
+ scope,
+}: {
+ task: 任务;
+ scope: string | null;
+}) {
+ const author = useMuse作者会话();
+ const query = useQuery({
+ queryKey: 任务查询键(scope, task.task_id, "calls"),
+ queryFn: async ({ signal }) => {
+ const { data } = await 客户端.GET("/api/v1/tasks/{task_id}/calls", {
+ params: { path: { task_id: task.task_id } },
+ signal,
+ });
+ if (!data) throw new Error("调用账目未返回。");
+ return data;
+ },
+ refetchOnWindowFocus: false,
+ });
+ return (
+
+ 调用费用与结果核对
+
+ {query.error && {query.error.message}
}
+ {query.data && (
+ <>
+
+ 账目版本 {query.data.accounting_revision} · 最后读取{" "}
+ {new Date(query.dataUpdatedAt).toLocaleTimeString("zh-CN")}
+
+
+ 执行任务
+ {task.state === "cancelled"
+ ? "已取消"
+ : task.state === "completed"
+ ? "已完成"
+ : "仍可接续"}
+ ;补录费用不会重新运行任务。
+
+ {query.data.calls.length === 0 && 尚无调用账目。
}
+ {author.data &&
+ query.data.calls.map((call) => (
+ query.refetch().then(() => {})}
+ />
+ ))}
+ >
+ )}
+
+ );
+}
+
+function Ui调用费用项({
+ author,
+ ledger,
+ call,
+ onRead,
+}: {
+ author: string;
+ ledger: 账目;
+ call: 账目["calls"][number];
+ onRead: () => Promise;
+}) {
+ const key =
+ "muse:call-accounting:v1:" +
+ JSON.stringify([author, ledger.task_id, call.call_id]);
+ const [load] = useState(() => {
+ try {
+ const raw = sessionStorage.getItem(key);
+ if (!raw) return { pending: undefined, raw: null };
+ const value: unknown = JSON.parse(raw);
+ if (!有效待确认(value))
+ throw new Error("待确认账目记录无法读取,请保留浏览器资料。");
+ return { pending: value, raw };
+ } catch (e) {
+ return {
+ pending: undefined,
+ raw: null,
+ error: e instanceof Error ? e.message : "账目缓冲无法读取。",
+ };
+ }
+ });
+ const [pending, setPending] = useState(load.pending),
+ observed = useRef(load.raw);
+ const [amount, setAmount] = useState(""),
+ [reference, setReference] = useState(""),
+ [statement, setStatement] = useState(""),
+ [zero, setZero] = useState(false),
+ [evidence, setEvidence] = useState("");
+ const [busy, setBusy] = useState(false),
+ [error, setError] = useState(load.error ?? ""),
+ [notice, setNotice] = useState("");
+ const busyRef = useRef(false);
+ async function send(kind: 待确认["kind"]) {
+ if (busyRef.current || load.error) return;
+ busyRef.current = true;
+ setBusy(true);
+ setError("");
+ setNotice("");
+ let frozen = pending;
+ try {
+ if (!frozen) {
+ if (kind === "settlement") {
+ const hash = Array.from(
+ new Uint8Array(
+ await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(statement),
+ ),
+ ),
+ (b) => b.toString(16).padStart(2, "0"),
+ ).join("");
+ frozen = {
+ kind,
+ body: {
+ command_id: crypto.randomUUID(),
+ expected_revision: ledger.accounting_revision,
+ amount,
+ currency: "USD",
+ source_reference: reference,
+ source_hash: hash,
+ source_statement: statement,
+ source_kind: "author_attestation",
+ zero_cost_confirmed: zero,
+ },
+ };
+ } else
+ frozen = {
+ kind,
+ body: { command_id: crypto.randomUUID(), evidence_id: evidence },
+ };
+ }
+ if (sessionStorage.getItem(key) !== observed.current)
+ throw new Error("待确认账目已由另一页面更新,请重新打开核对。");
+ const raw = JSON.stringify(frozen);
+ sessionStorage.setItem(key, raw);
+ observed.current = raw;
+ setPending(frozen);
+ const path = { task_id: ledger.task_id, call_id: call.call_id };
+ if (frozen.kind === "settlement") {
+ const { data } = await 客户端.POST(
+ "/api/v1/tasks/{task_id}/calls/{call_id}/settlements",
+ { params: { path }, body: frozen.body },
+ );
+ if (!data?.receipt_id) throw new Error("结算回执缺失,请重试原请求。");
+ setNotice(`已保存费用确认,回执 ${data.receipt_id}`);
+ } else {
+ const { data } = await 客户端.POST(
+ "/api/v1/tasks/{task_id}/calls/{call_id}/reconciliations",
+ { params: { path }, body: frozen.body },
+ );
+ if (data?.command_id !== frozen.body.command_id)
+ throw new Error("响应核对回执缺失,请重试原请求。");
+ setNotice("已核对保存响应,可另行选择恢复任务。");
+ }
+ if (sessionStorage.getItem(key) === observed.current)
+ sessionStorage.removeItem(key);
+ observed.current = null;
+ setPending(undefined);
+ await onRead();
+ } catch (cause) {
+ if (
+ cause instanceof 接口错误 &&
+ cause.status >= 400 &&
+ cause.status < 500 &&
+ cause.status !== 408
+ ) {
+ if (sessionStorage.getItem(key) === observed.current)
+ sessionStorage.removeItem(key);
+ observed.current = null;
+ setPending(undefined);
+ }
+ setError(cause instanceof Error ? cause.message : "账目尚未确认。");
+ } finally {
+ busyRef.current = false;
+ setBusy(false);
+ }
+ }
+ const locked = busy || !!pending || !!load.error;
+ const recoverable =
+ ledger.state === "paused" || ledger.state === "reconciling";
+ return (
+
+
+ {call.role_id} · {call.call_id} ·{" "}
+ {call.state === "settled"
+ ? `已结 ${call.actual_amount} USD`
+ : call.state === "released"
+ ? "已释放未发送预留"
+ : "费用待确认"}
+
+ {call.receipt_id && 账目回执 {call.receipt_id}
}
+ {pending ? (
+
+ ) : (
+ <>
+ {call.state === "unknown" && (
+
+ )}
+ {recoverable &&
+ call.state === "settled" &&
+ call.delivery_state === "missing" && (
+
+
+
+
+ )}
+ >
+ )}
+ {error && {error}
}
+ {notice && {notice}
}
+
+ );
+}
diff --git a/web/src/功能/作者经验/运行观察.tsx b/web/src/功能/作者经验/运行观察.tsx
index 69c9135..5958e1a 100644
--- a/web/src/功能/作者经验/运行观察.tsx
+++ b/web/src/功能/作者经验/运行观察.tsx
@@ -2,7 +2,7 @@
import { useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
-import { 查询键 } from "../../接口/查询键";
+import { 任务查询键 } from "../../接口/查询键";
import type { operations } from "../../接口/生成/类型";
import { 状态 } from "../任务运行/数据";
@@ -43,7 +43,7 @@ export function Ui运行观察({
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const command = useRef<{ signature: string; id: string }>();
- const queryKey = 查询键(scope, taskId, "运行观察");
+ const queryKey = 任务查询键(scope, taskId, "运行观察");
const observations = useQuery({
queryKey,
queryFn: async ({ signal }) => {
diff --git a/web/src/功能/创作对话/作者动作.tsx b/web/src/功能/创作对话/作者动作.tsx
index 15aa11a..5371b58 100644
--- a/web/src/功能/创作对话/作者动作.tsx
+++ b/web/src/功能/创作对话/作者动作.tsx
@@ -24,31 +24,37 @@ export function Ui作者动作({
const [失败, set失败] = useState("");
const 报告 = useMuse审校报告(scope, candidateId, 候选?.revision ?? null);
- const 读取并审阅 = useCallback(async () => {
- set失败("");
- try {
- const { data } = await 客户端.GET(
- "/api/v1/writing-candidates/{candidate_id}",
- { params: { path: { candidate_id: candidateId } } },
- );
- if (!data) throw new Error("候选读取为空");
- set候选(data);
- set审阅(
- await 打开审阅({
+ const 读取并审阅 = useCallback(
+ async (signal?: AbortSignal) => {
+ set失败("");
+ try {
+ const { data } = await 客户端.GET(
+ "/api/v1/writing-candidates/{candidate_id}",
+ { params: { path: { candidate_id: candidateId } }, signal },
+ );
+ if (!data) throw new Error("候选读取为空");
+ if (signal?.aborted) return;
+ set候选(data);
+ const result = await 打开审阅({
candidate_id: candidateId,
candidate_revision: data.revision,
candidate_hash: data.candidate_hash,
target_ref: data.document_id,
expected_revision: data.current_document_revision,
- }),
- );
- } catch (错误) {
- set失败(错误 instanceof Error ? 错误.message : "候选读取失败");
- }
- }, [candidateId]);
+ });
+ if (!signal?.aborted) set审阅(result);
+ } catch (错误) {
+ if (signal?.aborted) return;
+ set失败(错误 instanceof Error ? 错误.message : "候选读取失败");
+ }
+ },
+ [candidateId],
+ );
useEffect(() => {
- void 读取并审阅();
+ const controller = new AbortController();
+ void 读取并审阅(controller.signal);
+ return () => controller.abort();
}, [读取并审阅]);
return (
@@ -98,11 +104,17 @@ export function Ui作者动作({
)}
{失败 && }
{审阅 ? (
- void 读取并审阅()} />
+ void 读取并审阅()}
+ />
) : (
!失败 &&
)}
- 必需检查未通过的候选不能采纳;服务端按候选版本核对检查结论。
+
+ 必需检查未通过的候选不能采纳;服务端按候选版本核对检查结论。
+
);
}
diff --git a/web/src/功能/创作对话/数据.ts b/web/src/功能/创作对话/数据.ts
index 4517d23..3a47f99 100644
--- a/web/src/功能/创作对话/数据.ts
+++ b/web/src/功能/创作对话/数据.ts
@@ -1,7 +1,7 @@
/** 创作对话的数据接入:发起生成、事件解读与候选检查报告查询。 */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
-import { 查询键 } from "../../接口/查询键";
+import { 查询键, 任务列表键 } from "../../接口/查询键";
import { 接口错误 } from "../../接口/传输";
import type { operations } from "../../接口/生成/类型";
@@ -24,6 +24,8 @@ export const 事件名补充: Record = {
"candidate.saved": "候选已保存",
"check.completed": "检查完成",
"tool.read": "读取材料",
+ "tool.denied": "材料读取被拒",
+ "context.usage": "记录上下文用量",
};
/** 发起生成正文:命令身份由调用方生成并保持幂等。 */
@@ -52,7 +54,7 @@ export function useMuse发起生成(scope: string | null) {
);
},
onSuccess: () => {
- void cache.invalidateQueries({ queryKey: 查询键(scope, "tasks") });
+ void cache.invalidateQueries({ queryKey: 任务列表键(scope) });
},
});
}
@@ -78,8 +80,8 @@ export function useMuse审校报告(
params: {
path: { candidate_id: 身份 },
query: revision ? { revision } : undefined,
- signal,
},
+ signal,
})
).data!;
},
diff --git a/web/src/功能/创作对话/消息流.tsx b/web/src/功能/创作对话/消息流.tsx
index e8fd7bb..48a2b9e 100644
--- a/web/src/功能/创作对话/消息流.tsx
+++ b/web/src/功能/创作对话/消息流.tsx
@@ -5,15 +5,15 @@ import { 事件名补充 } from "./数据";
/** 把任务事件流折叠为可读消息;正文段落按写作顺序累积成稿。 */
export function 折叠消息(事件: 任务事件[]): {
- 正文段落: string[];
+ 正文段落: { key: string; text: string }[];
动作: { key: string; 文案: string }[];
} {
- const 正文段落: string[] = [];
+ const 正文段落: { key: string; text: string }[] = [];
const 动作: { key: string; 文案: string }[] = [];
for (const 条 of 事件) {
- const 载荷 = 条.payload as Record;
+ const 载荷 = 条.payload;
if (条.event_type === "text.delta" && typeof 载荷?.文本 === "string") {
- 正文段落.push(载荷.文本);
+ 正文段落.push({ key: 条.event_id, text: 载荷.文本 });
continue;
}
const 名 = 事件名补充[条.event_type] ?? 事件名[条.event_type];
@@ -26,7 +26,9 @@ export function Ui消息流({ 事件 }: { 事件: 任务事件[] }) {
const { 正文段落, 动作 } = 折叠消息(事件);
if (!正文段落.length && !动作.length)
return (
- 生成过程的事件会在这里按顺序出现;页面重开后仍可找回。
+
+ 生成过程的事件会在这里按顺序出现;页面重开后仍可找回。
+
);
return (
@@ -39,8 +41,8 @@ export function Ui消息流({ 事件 }: { 事件: 任务事件[] }) {
)}
{正文段落.length > 0 && (
- {正文段落.map((段, 序) => (
- {段}
+ {正文段落.map((段) => (
+ {段.text}
))}
)}
diff --git a/web/src/功能/正式变更/决策面板.tsx b/web/src/功能/正式变更/决策面板.tsx
index 0a56870..7bbe8e3 100644
--- a/web/src/功能/正式变更/决策面板.tsx
+++ b/web/src/功能/正式变更/决策面板.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef } from "react";
import type { 候选审阅 } from "./数据";
import { useMuse作者决定 } from "./审阅控制";
@@ -5,12 +6,21 @@ export function Ui决策面板({
review,
author,
onReopen,
+ onDecided,
}: {
review: 候选审阅;
author: string;
onReopen: () => void;
+ onDecided?: () => void;
}) {
const state = useMuse作者决定(review, author);
+ const notified = useRef
();
+ useEffect(() => {
+ if (state.receipt && notified.current !== state.receipt.receipt_id) {
+ notified.current = state.receipt.receipt_id;
+ onDecided?.();
+ }
+ }, [state.receipt, onDecided]);
const final = ["adopt", "reject"].includes(review.candidate.decision);
const labels = { adopt: "已采纳", reject: "已拒绝", defer: "已暂缓" };
return (
diff --git a/web/src/功能/正文写作/候选审阅.tsx b/web/src/功能/正文写作/候选审阅.tsx
index 579d1d4..0bc6571 100644
--- a/web/src/功能/正文写作/候选审阅.tsx
+++ b/web/src/功能/正文写作/候选审阅.tsx
@@ -1,6 +1,7 @@
-import { useRef, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link } from "react-router-dom";
+import { useMuse正文路由, 正文路由 } from "./路由参数";
import { useMuse作者会话 } from "../../应用/作者会话";
import { 接口错误 } from "../../接口/传输";
import { Ui正文编辑器 } from "../../界面/编辑器/正文编辑器";
@@ -8,26 +9,30 @@ import { Ui反馈状态 } from "../../界面/反馈状态";
import { 打开审阅, Ui决策面板, type 候选审阅 } from "../正式变更";
import {
读取正文,
- 读取候选列表,
+ 读取候选摘要,
+ 读取候选,
建立人工候选,
修改候选,
- type 正文草稿,
type 正文记录,
type 正文候选,
- type 保存请求,
- type 候选改稿请求,
+ type 候选摘要,
} from "./数据";
+import {
+ 候选浏览器缓冲,
+ 候选归属错误,
+ 请求属于范围,
+ type 改稿缓冲,
+ type 待存请求,
+} from "./候选缓冲";
import { Ui版本比较 } from "./版本比较";
export function Ui候选审阅页() {
- const { work_id = "", chapter_id = "" } = useParams();
+ const { work_id, chapter_id = "", branch } = useMuse正文路由();
const author = useMuse作者会话();
- const [params] = useSearchParams();
- const branch = params.get("branch") ?? "main";
if (!author.data) return ;
return (
{
- const [document, candidates] = await Promise.all([
- 读取正文(chapter, branch),
- 读取候选列表(chapter, branch),
- ]);
- if (document.work_id !== work) throw new Error("章节不属于当前作品。");
- return { document, candidates };
+ const cache = useQueryClient();
+ const rootKey = useMemo(
+ () => ["候选工作面", author, work, chapter, branch] as const,
+ [author, work, chapter, branch],
+ );
+ const [cursor, setCursor] = useState(null),
+ [previous, setPrevious] = useState<(string | null)[]>([]);
+ const [selectedId, setSelectedId] = useState();
+ const current = useQuery({
+ queryKey: ["正文", author, work, chapter, branch],
+ queryFn: async ({ signal }) => {
+ const document = await 读取正文(chapter, branch, signal);
+ if (
+ document.work_id !== work ||
+ document.chapter_id !== chapter ||
+ document.branch_id !== branch
+ )
+ throw new Error("章节不属于当前作品或分支。");
+ return document;
},
});
+ const query = useQuery({
+ queryKey: [...rootKey, "pages", cursor],
+ queryFn: ({ signal }) => 读取候选摘要(chapter, branch, cursor, signal),
+ gcTime: 60_000,
+ });
+ useEffect(() => {
+ const pages = cache
+ .getQueryCache()
+ .findAll({ queryKey: [...rootKey, "pages"] })
+ .sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt);
+ pages
+ .filter((page) => page.queryKey[page.queryKey.length - 1] !== cursor)
+ .slice(2)
+ .forEach((page) =>
+ cache.removeQueries({ queryKey: page.queryKey, exact: true }),
+ );
+ }, [cache, rootKey, query.data, cursor]);
+ useEffect(
+ () => () => {
+ void cache.cancelQueries({ queryKey: rootKey });
+ cache.removeQueries({ queryKey: rootKey });
+ },
+ [cache, rootKey],
+ );
const [review, setReview] = useState<候选审阅>(),
[error, setError] = useState("");
const [editing, setEditing] = useState<正文候选 | "new">();
@@ -65,18 +104,51 @@ function Ui候选工作面({
[notice, setNotice] = useState("");
async function reopen() {
setReview(undefined);
- await query.refetch();
+ await Promise.all([
+ query.refetch(),
+ current.refetch(),
+ cache.invalidateQueries({ queryKey: [...rootKey, "detail"] }),
+ ]);
}
- async function open(candidate: 正文候选) {
+ async function open(candidate: Pick<候选摘要, "candidate_id" | "revision">) {
+ setSelectedId(candidate.candidate_id);
setOpening(true);
setError("");
try {
+ const detailKey = [
+ ...rootKey,
+ "detail",
+ candidate.candidate_id,
+ candidate.revision,
+ ];
+ const detail = await cache.fetchQuery({
+ queryKey: detailKey,
+ queryFn: ({ signal }) => 读取候选(candidate.candidate_id, signal),
+ staleTime: 0,
+ gcTime: 60_000,
+ });
+ if (detail.chapter_id !== chapter || detail.branch_id !== branch)
+ throw new Error("候选不属于当前章节或分支。");
+ const details = cache
+ .getQueryCache()
+ .findAll({ queryKey: [...rootKey, "detail"] })
+ .sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt);
+ details
+ .filter(
+ (item) =>
+ item.queryKey[6] !== candidate.candidate_id ||
+ item.queryKey[7] !== candidate.revision,
+ )
+ .slice(1)
+ .forEach((item) =>
+ cache.removeQueries({ queryKey: item.queryKey, exact: true }),
+ );
const result = await 打开审阅({
- candidate_id: candidate.candidate_id,
- candidate_revision: candidate.revision,
- candidate_hash: candidate.candidate_hash,
- target_ref: candidate.document_id,
- expected_revision: candidate.current_document_revision,
+ candidate_id: detail.candidate_id,
+ candidate_revision: detail.revision,
+ candidate_hash: detail.candidate_hash,
+ target_ref: detail.document_id,
+ expected_revision: detail.current_document_revision,
});
setReview(result);
} catch (e) {
@@ -85,10 +157,16 @@ function Ui候选工作面({
setOpening(false);
}
}
- if (query.isPending) return ;
- if (query.error || !query.data)
+ if (current.isPending || query.isPending)
+ return ;
+ if (current.error || query.error || !current.data || !query.data)
return (
-
+
);
const states = {
undecided: "未决定",
@@ -103,10 +181,7 @@ function Ui候选工作面({
比较与取舍
正文候选
-
+
返回正文
@@ -116,7 +191,7 @@ function Ui候选工作面({
{
setEditing(undefined);
@@ -141,13 +216,14 @@ function Ui候选工作面({
另拟改稿
- {query.data.candidates.length === 0 ? (
+ {query.data.items.length === 0 ? (
这章还没有候选。
) : (
- query.data.candidates.map((candidate, index) => (
+ query.data.items.map((candidate, index) => (
+
+ {selectedId && !review && (
+
+ )}
{review && (
@@ -183,6 +299,15 @@ function Ui候选工作面({
review={review}
author={author}
onReopen={() => void reopen()}
+ onDecided={() => {
+ void Promise.all([
+ query.refetch(),
+ current.refetch(),
+ cache.invalidateQueries({
+ queryKey: [...rootKey, "detail"],
+ }),
+ ]);
+ }}
/>
)}
@@ -192,18 +317,6 @@ function Ui候选工作面({
);
}
-type 待存请求 =
- { kind: "create"; body: 保存请求 } | { kind: "edit"; body: 候选改稿请求 };
-type 改稿缓冲 = {
- document: 正文草稿;
- base_revision: number;
- candidate_revision?: number;
- candidate_hash?: string;
- mode: "edit" | "select";
- selected: string[];
- pending?: 待存请求;
-};
-
/** 候选缓冲与正文缓冲分开,保存前固定请求;刷新或结果未知后仍重放相同命令。 */
function Ui候选改稿({
author,
@@ -218,23 +331,41 @@ function Ui候选改稿({
onSaved: () => Promise;
onClose: () => void;
}) {
- const key = `muse:candidate-draft:${author}:${current.work_id}:${current.chapter_id}:${candidate?.candidate_id ?? "new"}`;
+ const scope = {
+ author_id: author,
+ work_id: current.work_id,
+ chapter_id: current.chapter_id,
+ branch_id: current.branch_id,
+ candidate_id: candidate?.candidate_id ?? "new",
+ };
+ const [storage] = useState(() => 候选浏览器缓冲(scope));
const [load] = useState(() => {
try {
- const saved = sessionStorage.getItem(key);
- return { value: saved ? (JSON.parse(saved) as 改稿缓冲) : undefined };
- } catch {
+ const value = storage.read();
+ return { value, legacy: value ? undefined : storage.legacy(), error: "" };
+ } catch (e) {
return {
value: undefined,
- error: "候选缓冲无法读取,请先保留浏览器资料。",
+ legacy: undefined,
+ origin: e instanceof 候选归属错误 ? e.scope : undefined,
+ error:
+ e instanceof Error
+ ? e.message
+ : "候选缓冲无法读取,请保留浏览器资料。",
};
}
});
+ const [generation, setGeneration] = useState(0);
+ const [legacy, setLegacy] = useState(load.legacy);
+ const [legacyChecking, setLegacyChecking] = useState(
+ !!load.legacy && !!candidate && !load.value,
+ );
const [buffer, setBuffer] = useState<改稿缓冲>(
() =>
load.value ?? {
document: candidate?.document ?? current.document,
base_revision: candidate?.base_revision ?? current.revision,
+ base_hash: current.document_hash,
candidate_revision: candidate?.revision,
candidate_hash: candidate?.candidate_hash,
mode: "edit",
@@ -248,18 +379,70 @@ function Ui候选改稿({
[error, setError] = useState(load.error ?? "");
const stale =
buffer.base_revision !== current.revision ||
- (candidate && buffer.candidate_revision !== candidate.revision);
+ (buffer.base_hash !== undefined &&
+ buffer.base_hash !== current.document_hash) ||
+ (candidate &&
+ (buffer.candidate_revision !== candidate.revision ||
+ buffer.candidate_hash !== candidate.candidate_hash));
+ useEffect(() => {
+ if (load.value || !load.legacy) return;
+ let alive = true;
+ const original = load.legacy.value;
+ async function migrate() {
+ try {
+ let confirmed =
+ original.pending?.kind === "create" &&
+ 请求属于范围(original.pending, scope);
+ if (candidate) {
+ const detail = await 读取候选(candidate.candidate_id);
+ confirmed =
+ detail.candidate_id === scope.candidate_id &&
+ detail.chapter_id === scope.chapter_id &&
+ detail.branch_id === scope.branch_id &&
+ detail.document_id === current.document_id &&
+ 请求属于范围(original.pending, scope);
+ }
+ if (alive && confirmed) {
+ storage.write(original);
+ latest.current = original;
+ setBuffer(original);
+ setGeneration((n) => n + 1);
+ setLegacy(undefined);
+ }
+ } catch (e) {
+ if (alive)
+ setError(
+ e instanceof Error ? e.message : "旧改稿归属无法确认,已保留。",
+ );
+ } finally {
+ if (alive) setLegacyChecking(false);
+ }
+ }
+ void migrate();
+ return () => {
+ alive = false;
+ };
+ // 该编辑器实例身份固定;查询刷新不能重绑草稿。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
function change(next: 改稿缓冲) {
latest.current = next;
setBuffer(next);
try {
- sessionStorage.setItem(key, JSON.stringify(next));
+ storage.write(next);
} catch {
setError("本地缓冲写入失败,请先复制保留改稿。");
}
}
async function save() {
- if (busyRef.current || composing || (stale && !latest.current.pending))
+ if (
+ busyRef.current ||
+ composing ||
+ load.error ||
+ legacyChecking ||
+ !请求属于范围(latest.current.pending, scope) ||
+ (stale && !latest.current.pending)
+ )
return;
busyRef.current = true;
setBusy(true);
@@ -292,16 +475,21 @@ function Ui候选改稿({
});
try {
const frozen = { ...draft, pending };
- sessionStorage.setItem(key, JSON.stringify(frozen));
+ storage.write(frozen);
latest.current = frozen;
setBuffer(frozen);
await (pending.kind === "create"
? 建立人工候选(pending.body)
: 修改候选(pending.body));
- sessionStorage.removeItem(key);
+ storage.clear();
await onSaved();
} catch (e) {
- if (e instanceof 接口错误 && e.status >= 400 && e.status < 500)
+ if (
+ e instanceof 接口错误 &&
+ e.status >= 400 &&
+ e.status < 500 &&
+ e.status !== 408
+ )
change({ ...latest.current, pending: undefined });
setError(e instanceof Error ? e.message : "候选尚未保存,请重试。");
} finally {
@@ -309,11 +497,67 @@ function Ui候选改稿({
setBusy(false);
}
}
- const locked = busy || !!buffer.pending;
+ const locked = busy || !!buffer.pending || !!load.error || legacyChecking;
return (
{candidate ? "调整候选" : "另拟改稿"}
这里的修改保留为候选,保存后重新比较再决定。
+ {load.origin && load.origin.author_id === author && (
+
+ 返回原分支 {load.origin.branch_id} 确认缓冲
+
+ )}
+ {legacy && (
+
+ 待确认旧改稿
+ 旧记录没有可用于当前分支的归属证明,原记录保持不变。
+ {legacy.value.pending?.kind === "create" && (
+
+ 返回原分支 {legacy.value.pending.body.branch_id} 确认保存结果
+
+ )}
+
+ {!legacy.value.pending && (
+
+ )}
+
+ )}
{load.value && 已恢复本浏览器中的候选改稿。
}
{stale && (
@@ -323,8 +567,12 @@ function Ui候选改稿({
) : (
[
+ s.author_id,
+ s.work_id,
+ s.chapter_id,
+ s.branch_id,
+ s.candidate_id,
+];
+export const 候选缓冲键 = (s: 候选范围) =>
+ "muse:candidate-draft:v2:" + JSON.stringify(identity(s));
+export const 旧候选缓冲键 = (s: 候选范围) =>
+ `muse:candidate-draft:${s.author_id}:${s.work_id}:${s.chapter_id}:${s.candidate_id}`;
+const object = (v: unknown): v is Record =>
+ typeof v === "object" && v !== null;
+function 有效范围(v: unknown): v is 候选范围 {
+ return (
+ object(v) &&
+ ["author_id", "work_id", "chapter_id", "branch_id", "candidate_id"].every(
+ (k) => typeof v[k] === "string",
+ )
+ );
+}
+export class 候选归属错误 extends Error {
+ constructor(public readonly scope: 候选范围) {
+ super(
+ `候选缓冲属于分支 ${scope.branch_id},原记录已保留,请回原分支确认。`,
+ );
+ }
+}
+export function 有效正文(v: unknown): v is 正文草稿 {
+ return (
+ object(v) &&
+ v.format_version === 1 &&
+ Array.isArray(v.paragraphs) &&
+ v.paragraphs.every(
+ (p) =>
+ object(p) &&
+ typeof p.paragraph_id === "string" &&
+ Array.isArray(p.content) &&
+ p.content.every(
+ (n) =>
+ object(n) &&
+ ((n.type === "text" &&
+ typeof n.text === "string" &&
+ Array.isArray(n.marks) &&
+ n.marks.every((m) => m === "strong" || m === "emphasis")) ||
+ (n.type === "line_break" && typeof n.newline === "string")),
+ ),
+ )
+ );
+}
+export function 有效改稿(v: unknown): v is 改稿缓冲 {
+ if (
+ !object(v) ||
+ !有效正文(v.document) ||
+ !Number.isInteger(v.base_revision) ||
+ !["edit", "select"].includes(String(v.mode)) ||
+ !Array.isArray(v.selected) ||
+ !v.selected.every((x) => typeof x === "string")
+ )
+ return false;
+ if (v.pending === undefined) return true;
+ const p = v.pending;
+ if (
+ !object(p) ||
+ !object(p.body) ||
+ typeof p.body.command_id !== "string" ||
+ !p.body.command_id
+ )
+ return false;
+ if (p.kind === "create")
+ return (
+ typeof p.body.chapter_id === "string" &&
+ typeof p.body.branch_id === "string" &&
+ Number.isInteger(p.body.expected_revision) &&
+ 有效正文(p.body.document)
+ );
+ return (
+ p.kind === "edit" &&
+ typeof p.body.candidate_id === "string" &&
+ Number.isInteger(p.body.expected_candidate_revision) &&
+ typeof p.body.expected_candidate_hash === "string" &&
+ (有效正文(p.body.document) ||
+ (Array.isArray(p.body.selected_changes) &&
+ p.body.selected_changes.every((x) => typeof x === "string")))
+ );
+}
+export function 请求属于范围(p: 待存请求 | undefined, s: 候选范围) {
+ return (
+ !p ||
+ (p.kind === "create"
+ ? s.candidate_id === "new" &&
+ p.body.chapter_id === s.chapter_id &&
+ p.body.branch_id === s.branch_id
+ : p.body.candidate_id === s.candidate_id)
+ );
+}
+export function 候选浏览器缓冲(
+ scope: 候选范围,
+ storage: Storage = sessionStorage,
+) {
+ const key = 候选缓冲键(scope),
+ legacyKey = 旧候选缓冲键(scope),
+ owner = crypto.randomUUID();
+ let observed: string | null = null;
+ let readable = false;
+ let written: string | undefined;
+ return {
+ key,
+ read(): 改稿缓冲 | undefined {
+ readable = false;
+ observed = storage.getItem(key);
+ if (observed === null) {
+ readable = true;
+ return undefined;
+ }
+ const value: unknown = JSON.parse(observed);
+ if (
+ !object(value) ||
+ value.schema_version !== 2 ||
+ !有效范围(value.scope) ||
+ !有效改稿(value.draft)
+ )
+ throw new Error("候选缓冲结构损坏,原记录已保留,请导出浏览器资料。");
+ const storedScope = value.scope;
+ if (
+ identity(scope).some((v, i) => v !== identity(storedScope)[i]) ||
+ !请求属于范围(value.draft.pending, scope)
+ ) {
+ const original =
+ value.draft.pending?.kind === "create"
+ ? {
+ ...value.scope,
+ branch_id: value.draft.pending.body.branch_id,
+ chapter_id: value.draft.pending.body.chapter_id,
+ }
+ : value.scope;
+ throw new 候选归属错误(original);
+ }
+ if (
+ scope.candidate_id !== "new" &&
+ (!Number.isInteger(value.draft.candidate_revision) ||
+ typeof value.draft.candidate_hash !== "string")
+ )
+ throw new Error("候选缓冲缺少版本依据,原记录已保留。");
+ readable = true;
+ return value.draft;
+ },
+ legacy() {
+ const raw = storage.getItem(legacyKey);
+ if (raw === null) return undefined;
+ const value: unknown = JSON.parse(raw);
+ if (!有效改稿(value))
+ throw new Error("旧候选缓冲损坏,原记录已保留,请导出浏览器资料。");
+ if (
+ scope.candidate_id !== "new" &&
+ (!Number.isInteger(value.candidate_revision) ||
+ typeof value.candidate_hash !== "string")
+ )
+ throw new Error("旧候选缓冲缺少版本依据,原记录已保留。");
+ return { value, raw };
+ },
+ write(draft: 改稿缓冲) {
+ if (!readable) throw new Error("缓冲未经有效读取,不能覆盖原记录。");
+ if (!请求属于范围(draft.pending, scope))
+ throw new Error("原请求属于其他分支,不能从当前页面发送。");
+ if (storage.getItem(key) !== observed)
+ throw new Error("候选缓冲已由其他会话更新,请保留当前文字并重新打开。");
+ const text = JSON.stringify({
+ schema_version: 2,
+ scope,
+ owner_id: owner,
+ draft,
+ });
+ storage.setItem(key, text);
+ observed = written = text;
+ },
+ discard() {
+ if (storage.getItem(key) !== observed)
+ throw new Error("候选缓冲已由其他会话更新,不能放弃。");
+ storage.removeItem(key);
+ observed = null;
+ written = undefined;
+ },
+ clear() {
+ if (written !== undefined && storage.getItem(key) === written) {
+ storage.removeItem(key);
+ observed = null;
+ written = undefined;
+ }
+ },
+ };
+}
diff --git a/web/src/功能/正文写作/数据.ts b/web/src/功能/正文写作/数据.ts
index c80c930..497f59f 100644
--- a/web/src/功能/正文写作/数据.ts
+++ b/web/src/功能/正文写作/数据.ts
@@ -76,3 +76,34 @@ export async function 修改候选(body: 候选改稿请求) {
if (!data) throw new Error("候选修改回执为空,请重试。");
return data;
}
+
+export async function 读取候选(candidate_id: string, signal?: AbortSignal) {
+ const { data } = await 客户端.GET(
+ "/api/v1/writing-candidates/{candidate_id}",
+ { params: { path: { candidate_id } }, signal },
+ );
+ if (!data) throw new Error("候选详情读取失败。");
+ return data;
+}
+
+export type 候选摘要 =
+ operations["list_writing_candidate_summaries"]["responses"][200]["content"]["application/json"]["items"][number];
+export async function 读取候选摘要(
+ chapter_id: string,
+ branch_id: string,
+ cursor: string | null,
+ signal?: AbortSignal,
+) {
+ const { data } = await 客户端.GET(
+ "/api/v1/chapters/{chapter_id}/writing-candidate-summaries",
+ {
+ params: {
+ path: { chapter_id },
+ query: { branch_id, cursor: cursor ?? undefined, limit: 20 },
+ },
+ signal,
+ },
+ );
+ if (!data) throw new Error("候选摘要未返回。");
+ return data;
+}
diff --git a/web/src/功能/正文写作/正文页.tsx b/web/src/功能/正文写作/正文页.tsx
index 4244749..b425c76 100644
--- a/web/src/功能/正文写作/正文页.tsx
+++ b/web/src/功能/正文写作/正文页.tsx
@@ -1,13 +1,19 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { Link } from "react-router-dom";
import { useMuse作者会话 } from "../../应用/作者会话";
+import { useMuse正文路由, 正文路由 } from "./路由参数";
import { Ui章节目录 } from "../作品规划";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { Ui正文编辑器 } from "../../界面/编辑器/正文编辑器";
import { 正文文本 } from "../../界面/编辑器/文档适配";
import { 读取正文, 保存正文, type 正文记录 } from "./数据";
-import { 浏览器缓冲, type 编辑范围, type 缓冲记录 } from "./编辑缓冲";
+import {
+ 浏览器缓冲,
+ type 编辑范围,
+ type 缓冲记录,
+ type 缓冲存储,
+} from "./编辑缓冲";
import { 创建编辑会话 } from "./编辑会话";
import { useMuse自动保存 } from "./保存控制";
import { Ui版本比较 } from "./版本比较";
@@ -16,16 +22,19 @@ import { Ui选段改写 } from "./选段改写";
import type { 原文坐标 } from "../../界面/编辑器/选区定位";
export function Ui正文页() {
- const { work_id = "", chapter_id } = useParams();
+ const { work_id, chapter_id, branch } = useMuse正文路由();
const author = useMuse作者会话();
- const [params] = useSearchParams();
- const branch = params.get("branch") ?? "main";
return (
{chapter_id && author.data ? (
{
const result = await 读取正文(scope.chapter_id, scope.branch_id, signal);
- if (result.work_id !== scope.work_id)
+ if (
+ result.work_id !== scope.work_id ||
+ result.chapter_id !== scope.chapter_id ||
+ result.branch_id !== scope.branch_id
+ )
throw new Error("章节不属于当前作品,请从目录重新打开。");
return result;
},
@@ -88,6 +101,10 @@ function Ui缓冲恢复({
onRestored: () => Promise;
}) {
const [buffer] = useState(() => 浏览器缓冲(scope));
+ useEffect(() => {
+ buffer.activate?.();
+ return () => buffer.dispose?.();
+ }, [buffer]);
const [local] = useState(() => {
try {
return { value: buffer.read(), error: "" };
@@ -129,6 +146,7 @@ function Ui缓冲恢复({
{local.error && }
Promise;
}) {
const [session] = useState(() =>
- 创建编辑会话(scope, record, 保存正文, 浏览器缓冲(scope), restored),
+ 创建编辑会话(scope, record, 保存正文, buffer, restored),
);
const state = useMuse自动保存(session);
const [selection, setSelection] = useState<{
@@ -184,8 +204,7 @@ function Ui编辑正文({
{state.composing ? "正在输入…" : labels[state.status]}
- 版本 {state.base_revision} ·{" "}
- {Array.from(正文文本(state.draft)).length} 字符
+ 版本 {state.base_revision} · {state.codepoints} 字符