Files
UHD_Media_Tool/apps/web/app/components/agent/mock.ts
T
rootandClaude Opus 5.5 28974d1e1a feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
  into the camofox profile before the first tab opens; password accounts
  without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
  accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
  every 10 min of activity, and from refresh-login when the stored cookie is
  invalid (verified before saving). user_info.cookieOrigin records
  source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
  under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
  logged in (logout link present), 5 cookies read back.

Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:25:07 +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);
}