Files
UHD_Media_Tool/apps/web/app/components/agent/mock.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

440 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
type Step = { delayMs: number; body: AgentRunEventBody };
type Entry = {
run: AgentRunView;
steps: Step[];
startedAt: number;
abortedAt: number | null;
appended: RunMessage[];
};
const store = new Map<string, Entry>();
let seeded = false;
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const iso = (ms: number) => new Date(ms).toISOString();
const USAGE: AgentUsageTotals = {
inputTokens: 18_420,
outputTokens: 1_236,
cacheReadTokens: 12_800,
cacheWriteTokens: 2_048,
totalTokens: 19_656,
costUsd: 0.042,
};
function chunks(text: string, size = 6): string[] {
const out: string[] = [];
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
return out;
}
const MOCK_DECISION = "00000000-0000-4000-8000-00000000d001";
const MOCK_APPROVAL = "00000000-0000-4000-8000-00000000a001";
const MOCK_PLAN = "00000000-0000-4000-8000-00000000b001";
function script(goal: string, opts: AgentSessionOptions): Step[] {
const s: Step[] = [];
let t = 0;
const push = (dt: number, body: AgentRunEventBody) => {
t += dt;
s.push({ delayMs: t, body });
};
const say = (text: string) => {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
const steps: AgentPlanStep[] = [
{ id: "s1", title: "查本地库存和收录版本", status: "running", note: null },
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
];
let revision = 0;
const plan = (id: string, status: AgentPlanStep["status"], next?: string) => {
for (const st of steps) {
if (st.id === id) st.status = status;
if (next && st.id === next) st.status = "running";
}
push(150, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
};
push(0, { type: "run_started", goal, resumed: false });
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
say("先确认本地库存与收录版本,再去站点找候选。");
push(400, {
type: "tool_call_start",
toolCallId: "t1",
capabilityId: "inventory.query",
inputSummary: '{"query":"沙丘:第二部","year":2024}',
recovery: false,
});
push(900, {
type: "tool_call_end",
toolCallId: "t1",
capabilityId: "inventory.query",
isError: false,
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
recovery: false,
});
plan("s1", "done", "s2");
push(300, {
type: "tool_call_start",
toolCallId: "t2",
capabilityId: "release.search",
inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}',
recovery: false,
});
push(1200, {
type: "tool_call_end",
toolCallId: "t2",
capabilityId: "release.search",
isError: false,
outputSummary:
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
recovery: false,
});
push(300, { type: "compaction", phase: "start", reason: "threshold" });
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
plan("s2", "done", "s3");
if (opts.proposeFirst) {
push(300, {
type: "options_proposed",
invocationId: "mock-options",
decisionId: MOCK_DECISION,
question: "收哪一个 4K 版本?",
options: [
{
id: "a",
title: "① REMUX 78.4 GB",
summary: "画质最好,符合 archival 的硬条件;免费还剩 6 小时。",
risks: ["缓存卷使用率会到 86%"],
estimatedCost: "78.4 GB",
},
{
id: "b",
title: "② WEB-DL 24.1 GB",
summary: "体积小三分之二,不满足 archival 的 REMUX 条件。",
risks: ["之后可能还要升级"],
estimatedCost: "24.1 GB",
},
],
recommendedId: "a",
stepId: "s3",
expiresAt: iso(Date.now() + 1_800_000),
timeoutAction: "recommended",
});
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
push(300, {
type: "tool_call_start",
toolCallId: "t3",
capabilityId: "download.plan",
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
recovery: false,
});
push(800, {
type: "tool_call_end",
toolCallId: "t3",
capabilityId: "download.plan",
isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false,
});
plan("s3", "done", "s4");
push(300, {
type: "tool_call_rejected",
toolCallId: "t4",
toolName: "qb_delete_torrent",
capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
});
push(300, {
type: "tool_call_start",
toolCallId: "t5",
capabilityId: "download.submit",
inputSummary: '{"planId":"…9a2e"}',
recovery: false,
});
push(1000, {
type: "tool_call_end",
toolCallId: "t5",
capabilityId: "download.submit",
isError: false,
outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}',
recovery: false,
});
push(200, {
type: "approval_requested",
approvalId: MOCK_APPROVAL,
decisionId: null,
invocationId: "mock-submit",
capabilityId: "download.submit",
planType: "acquisition_plan",
planId: MOCK_PLAN,
payloadHash: "sha256:9a2e…",
expectedRevision: 1,
riskLevel: "high",
summary: "下载《沙丘:第二部》:Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX(78.4 GB)",
stepId: "s4",
expiresAt: iso(Date.now() + 86_400_000),
});
say("提交下载需要审批,已在「提交下载」这一步发起。批准后继续。");
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
function baseRun(
id: string,
goal: string,
opts: AgentSessionOptions,
createdAt: number,
status: AgentRunView["status"],
) {
return {
id,
createdAt: iso(createdAt),
updatedAt: iso(createdAt),
taskId: null,
parentRunId: null,
trigger: "user",
goal,
status,
provider: "anthropic",
model: "claude-sonnet",
thinkingLevel: "medium",
scope: { proposeFirst: opts.proposeFirst, interactive: true },
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
usage: {},
evidenceVersion: null,
sessionState: null,
summary: null,
toolCallCount: 0,
lastEventSeq: 0,
error: null,
cancelRequestedAt: null,
startedAt: iso(createdAt),
finishedAt: null,
createdBy: "admin",
dedupeKey: null,
depth: 0,
spawnReason: null,
} satisfies AgentRunView;
}
function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: number): Entry {
const id = uuid(n);
const e: Entry = {
run: baseRun(id, goal, opts, startedAt, "running"),
steps: script(goal, opts),
startedAt,
abortedAt: null,
appended: [],
};
store.set(id, e);
return e;
}
function seed() {
if (seeded) return;
seeded = true;
const now = Date.now();
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
failed.run = { ...failed.run, trigger: "schedule" };
const evt = add(
"新资源到达:看看 奥本海默 的 REMUX 是否值得替换现有版本",
{ proposeFirst: false },
now - 3 * 86_400_000,
4,
);
evt.run = { ...evt.run, trigger: "event" };
failed.steps = [
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
{
delayMs: 100,
body: {
type: "tool_call_start",
toolCallId: "x1",
capabilityId: "task.inspect",
inputSummary: '{"instance":"qb-main"}',
recovery: false,
},
},
{
delayMs: 200,
body: {
type: "tool_call_end",
toolCallId: "x1",
capabilityId: "task.inspect",
isError: true,
outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)",
recovery: false,
},
},
{
delayMs: 300,
body: {
type: "run_failed",
reason: "budget",
budget: "duration",
message: "超出单次运行时长预算(600 秒)",
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
},
},
];
}
function revealed(e: Entry, now = Date.now()): AgentRunEvent[] {
const cut = e.abortedAt ?? now;
const out: AgentRunEvent[] = [];
let seq = 0;
for (const st of e.steps) {
if (e.startedAt + st.delayMs > cut) break;
out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent);
}
if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) {
out.push({
type: "run_failed",
reason: "aborted",
message: "用户中止",
usage: USAGE,
runId: e.run.id,
seq: seq++,
at: iso(e.abortedAt),
});
}
return out;
}
function view(e: Entry): AgentRunView {
const evs = revealed(e);
const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed");
const tools = evs.filter((x) => x.type === "tool_call_start").length;
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
const status: AgentRunView["status"] = !end
? "running"
: end.type === "run_completed"
? "succeeded"
: end.reason === "budget"
? "budget_exceeded"
: end.reason === "aborted"
? "cancelled"
: "failed";
const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length));
return {
...e.run,
status,
toolCallCount: tools,
lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0,
usage: end
? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt }
: {
inputTokens: Math.round(USAGE.inputTokens * frac),
outputTokens: Math.round(USAGE.outputTokens * frac),
totalTokens: Math.round(USAGE.totalTokens * frac),
costUsd: +(USAGE.costUsd * frac).toFixed(4),
durationMs: Date.now() - e.startedAt,
},
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
finishedAt: end ? end.at : null,
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
};
}
export function MOCK_RUNS(): AgentRunView[] {
seed();
return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot {
seed();
const e = store.get(id);
if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" });
if (op?.abort && !e.abortedAt) e.abortedAt = Date.now();
if (op?.append) {
// 追加消息:模拟 Agent 回一句确认
const at = Date.now() - e.startedAt;
e.appended.push({
id: uuid(9000 + e.appended.length),
runId: id,
createdAt: iso(Date.now()),
updatedAt: iso(Date.now()),
text: op.append,
status: "pending",
createdBy: "user:mock",
consumedAt: null,
});
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
delayMs: at + 200 + i * 60,
body: { type: "text_delta", delta: c },
}));
if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply);
e.steps.sort((a, b) => a.delayMs - b.delayMs);
}
const run = view(e);
return { run, messages: e.appended, report: report(e, run) };
}
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
function report(e: Entry, run: AgentRunView): GoalReport | null {
if (!run.finishedAt) return null;
const evs = revealed(e);
const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError);
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
return {
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
summary: submitted
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
: run.status === "succeeded"
? "本次没有产生下载或整理动作"
: "运行未完成,没有产生业务动作",
submitted: submitted ? 1 : 0,
imported: 0,
inProgress: submitted ? 1 : 0,
failed: 0,
plans: submitted
? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }]
: [],
downloads: submitted
? [
{
downloadTaskId: uuid(7002),
planId: uuid(7001),
status: "downloading",
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
},
]
: [],
organization: [],
modelText: text || null,
generatedAt: run.finishedAt,
};
}
export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
seed();
const e = store.get(id);
if (!e) return [];
return revealed(e).filter((x) => x.seq > after);
}
export function mockCreateRun(goal: string, opts: AgentSessionOptions): AgentRunView {
seed();
const e = add(goal, opts, Date.now(), store.size + 1);
return view(e);
}