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" && ( +
    { + event.preventDefault(); + void send("settlement"); + }} + > +

    请填写你核对过的费用依据,此记录属于作者确认。

    + + +