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]>
This commit is contained in:
1 parent
901b01d1a1
commit
28974d1e1a
58 files changed
+2753
-1022
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAccountCookie } from "../../sources-identity/service/credentials.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定):
|
||||
// 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录;
|
||||
// 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GOOD = "good-session-value-7f3a";
|
||||
const STALE = "stale-session-value-11";
|
||||
const FRESH = "fresh-browser-login-9c2";
|
||||
|
||||
describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => {
|
||||
let h: BrowserHarness;
|
||||
let cookieAcc: string;
|
||||
|
||||
const setCookieAccount = async (cookie: string) => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
};
|
||||
const open = async (path = "/torrents.php") =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const closeAll = async () => {
|
||||
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
});
|
||||
cookieAcc = h.accountId;
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("automation");
|
||||
expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`);
|
||||
expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 });
|
||||
expect(JSON.stringify(s)).not.toContain(GOOD);
|
||||
// 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure
|
||||
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
|
||||
const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`);
|
||||
expect(inj).toBeGreaterThanOrEqual(0);
|
||||
expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs"));
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({
|
||||
value: GOOD,
|
||||
domain: "audiences.example",
|
||||
secure: true,
|
||||
});
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => {
|
||||
h.mock.loggedIn.clear();
|
||||
h.mock.cookieJars.clear();
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("waiting");
|
||||
expect(s.waitingReason).toBe("login_required");
|
||||
expect(s.credential).toMatchObject({ status: "expired" });
|
||||
expect(s.credential.message).toContain("登录一次");
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow();
|
||||
expect(d.summary).toContain("交还后全站生效");
|
||||
|
||||
// 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址)
|
||||
expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200);
|
||||
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
|
||||
h.mock.loggedIn.add(s.profileKey);
|
||||
h.mock.cookieJars.get(s.profileKey)!.push(
|
||||
{
|
||||
name: "c_secure_pass",
|
||||
value: FRESH,
|
||||
domain: "audiences.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
},
|
||||
// 别的域名的 Cookie 不回写
|
||||
{
|
||||
name: "_ga",
|
||||
value: "tracker",
|
||||
domain: ".analytics.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
},
|
||||
);
|
||||
tab.url = `${ORIGIN}/torrents.php`;
|
||||
const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
const acc = await readAccountCookie(h.ctx, cookieAcc);
|
||||
expect(acc).toMatchObject({ source: "browser", applicable: true });
|
||||
expect(acc!.header).toContain(`c_secure_pass=${FRESH}`);
|
||||
expect(acc!.header).toContain("c_secure_uid=1001");
|
||||
expect(acc!.header).not.toContain("_ga");
|
||||
const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow();
|
||||
expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" });
|
||||
const view = (await h.req(`/browser/sessions/${s.id}`)).body.data;
|
||||
expect(view.credential).toMatchObject({ status: "injected", source: "browser" });
|
||||
expect(view.credential.syncedAt).toBeTruthy();
|
||||
expect(JSON.stringify(view)).not.toContain(FRESH);
|
||||
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
expect(s.credential.status).toBe("injected");
|
||||
// 站点在浏览器里轮换了会话 Cookie
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44";
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44");
|
||||
|
||||
// 登录页:不回写
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s2 = await open("/login.php");
|
||||
h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk";
|
||||
await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
});
|
||||
|
||||
it("有活动时每 10 分钟读回一次", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5";
|
||||
// 刚打开:不读回
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
// 会话创建时间拨到 11 分钟前:下一次活动触发后台读回
|
||||
await h.db
|
||||
.updateTable("browser_session")
|
||||
.set({ created_at: new Date(Date.now() - 11 * 60_000) })
|
||||
.where("id", "=", s.id)
|
||||
.execute();
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
await expect
|
||||
.poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 })
|
||||
.toContain("c_secure_pass=periodic-rotated-5");
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("匿名会话与 API Key 账号不注入", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } });
|
||||
expect(r.body.data.credential).toMatchObject({ status: "not_applicable" });
|
||||
await closeAll();
|
||||
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "api_key", secret_ref: ref })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
const before = h.mock.callsTo(/\/cookies$/).length;
|
||||
const s = await open("/");
|
||||
expect(s.credential).toMatchObject({ status: "not_applicable" });
|
||||
expect(h.mock.callsTo(/\/cookies$/).length).toBe(before);
|
||||
await closeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => {
|
||||
let h: BrowserHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred_noplugin", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
cookieApi: "none",
|
||||
});
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入报不支持,会话照常打开并说明原因", async () => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref })
|
||||
.where("id", "=", h.accountId)
|
||||
.execute();
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 });
|
||||
expect(r.body.data.credential.message).toContain("umt-cookies");
|
||||
});
|
||||
});
|
||||
@@ -53,11 +53,23 @@ export interface BrowserHarness {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createBrowserHarness(name: string, opts: { captcha?: boolean } = {}): Promise<BrowserHarness> {
|
||||
export async function createBrowserHarness(
|
||||
name: string,
|
||||
opts: {
|
||||
captcha?: boolean;
|
||||
/** BR-2:mock 站点按这个 Cookie 判定登录态 */
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
cookieApi?: "plugin" | "upstream" | "none";
|
||||
cookieImportKey?: string;
|
||||
} = {},
|
||||
): Promise<BrowserHarness> {
|
||||
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
|
||||
const mock = await createMockCamofox({
|
||||
accessKey: ACCESS_KEY,
|
||||
...(opts.cookieApi ? { cookieApi: opts.cookieApi } : {}),
|
||||
...(opts.cookieImportKey ? { cookieImportKey: opts.cookieImportKey } : {}),
|
||||
site: {
|
||||
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
|
||||
origin: ORIGIN,
|
||||
username: "fixture-user",
|
||||
password: PASSWORD,
|
||||
|
||||
@@ -40,7 +40,10 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu
|
||||
registerRoutes(app, ctx) {
|
||||
registerBrowserRoutes(app, () => service(ctx));
|
||||
},
|
||||
registerTasks() {},
|
||||
registerTasks(_registry, ctx) {
|
||||
// worker 里的 refresh-login 也要能从浏览器 profile 读回 Cookie(BR-2):建服务即注册读回钩子
|
||||
service(ctx);
|
||||
},
|
||||
registerCapabilities(registry, ctx) {
|
||||
service(ctx);
|
||||
registerBrowserCapabilities(registry, () => service(ctx));
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
type BrowserProvider,
|
||||
type BrowserSecretResolver,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
type DownloadSink,
|
||||
type ElementTarget,
|
||||
getSiteDefinition,
|
||||
headerFromCookies,
|
||||
hostAllowed,
|
||||
imageDimensions,
|
||||
type PageMetrics,
|
||||
@@ -22,7 +25,9 @@ import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserController,
|
||||
BrowserCookie,
|
||||
BrowserCoordinateSpace,
|
||||
BrowserCredentialState,
|
||||
BrowserDownloadVerification,
|
||||
BrowserEvidenceRef,
|
||||
BrowserHealth,
|
||||
@@ -42,6 +47,12 @@ import {
|
||||
sql,
|
||||
} from "@umt/db";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { refreshSiteAccount } from "../sources-identity/service/account.ts";
|
||||
import {
|
||||
readAccountCookie,
|
||||
registerBrowserCookieReader,
|
||||
writeBrowserCookie,
|
||||
} from "../sources-identity/service/credentials.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
|
||||
@@ -134,8 +145,13 @@ export interface RecoveryState {
|
||||
waitingDecisionId?: string | null;
|
||||
releasedAt?: string;
|
||||
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
|
||||
/** BR-2:打开时的凭据注入结果与最近一次回写检查 */
|
||||
credential?: BrowserCredentialState;
|
||||
}
|
||||
|
||||
/** 有活动的会话每隔多久从浏览器读回一次 Cookie */
|
||||
export const COOKIE_SYNC_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
|
||||
export const ENV_PROVIDER_NAME = "camofox-env";
|
||||
const ENV_MANAGED = "env";
|
||||
@@ -150,6 +166,8 @@ export class BrowserService {
|
||||
readonly opts: BrowserModuleOptions = {},
|
||||
) {
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
|
||||
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
|
||||
}
|
||||
|
||||
// ---------- 浏览器服务 ----------
|
||||
@@ -622,6 +640,7 @@ export class BrowserService {
|
||||
lease: lease ? toLease(lease) : null,
|
||||
needsSnapshot: rs.needsSnapshot === true,
|
||||
waitingReason: row.controller === "waiting" || row.controller === "human" ? (rs.waitingReason ?? null) : null,
|
||||
credential: rs.credential ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -680,6 +699,7 @@ export class BrowserService {
|
||||
// 数据库里已没有未关闭的会话:本进程内存里同 profile 还开着的是别的进程关掉的,标关闭,否则适配器拒绝新开
|
||||
for (const m of await provider.listSessions())
|
||||
if (m.profileKey === profileKey && m.controller !== "closed") provider.setController(m.id, "closed");
|
||||
const prep = await this.prepareCookies(site, accountId);
|
||||
await this.rateLimit(site);
|
||||
let res: Awaited<ReturnType<BrowserProvider["openSession"]>>;
|
||||
try {
|
||||
@@ -690,11 +710,28 @@ export class BrowserService {
|
||||
profileKey,
|
||||
initialUrl: req.url ?? site.base_url,
|
||||
budget,
|
||||
...(prep.cookies.length ? { cookies: prep.cookies } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
throw fromBrowserError(e);
|
||||
}
|
||||
const s = res.session;
|
||||
const credential: BrowserCredentialState = { ...prep.state };
|
||||
if (res.cookies) {
|
||||
credential.status = res.cookies.status;
|
||||
credential.count = res.cookies.count;
|
||||
if (res.cookies.status === "unsupported")
|
||||
credential.message = "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件),没有注入账号登录态";
|
||||
else if (res.cookies.status === "failed") credential.message = "注入账号 Cookie 失败";
|
||||
}
|
||||
// 注入了 Cookie 仍落在登录页:Cookie 过期。Cookie 账号(没有密码可自动登录)转"需要人工登录":在画面里登录一次,
|
||||
// 交还 / 关闭时回写,全站生效;账密账号留给 Agent 用凭证注入登录(browser.interact credential),只记状态
|
||||
const loginPage = provider.sessionInfo(s.id)?.loginPage === true;
|
||||
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
|
||||
if (credential.status === "injected" && loginPage) {
|
||||
credential.status = "expired";
|
||||
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态";
|
||||
}
|
||||
await this.ctx.db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.insertInto("browser_session")
|
||||
@@ -708,7 +745,7 @@ export class BrowserService {
|
||||
current_url: s.currentUrl,
|
||||
last_activity_at: new Date(s.lastActivityAt),
|
||||
upstream_ref: JSON.stringify(s.upstreamRef),
|
||||
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true }),
|
||||
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true, credential }),
|
||||
})
|
||||
.execute();
|
||||
await trx
|
||||
@@ -722,11 +759,160 @@ export class BrowserService {
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
if (needHuman) await this.enterWaiting(await this.sessionRow(s.id), "login_required");
|
||||
return s.id;
|
||||
}
|
||||
|
||||
// ---------- 凭据统一(BR-2) ----------
|
||||
|
||||
/**
|
||||
* 打开会话前准备要注入的 Cookie:匿名会话与 API Key 类站点(馒头:网页登录态与 API Key 无关)不注入;
|
||||
* 账密账号还没有会话 Cookie 时先走一次 refresh-login(与站点中心"刷新登录"同一流程)再取。
|
||||
*/
|
||||
private async prepareCookies(
|
||||
site: SiteRow,
|
||||
accountId: string | null,
|
||||
): Promise<{ cookies: BrowserCookie[]; state: BrowserCredentialState; authKind: string | null }> {
|
||||
const state = (
|
||||
status: BrowserCredentialState["status"],
|
||||
message: string | null,
|
||||
extra: Partial<BrowserCredentialState> = {},
|
||||
): BrowserCredentialState => ({
|
||||
status,
|
||||
source: null,
|
||||
sourceAt: null,
|
||||
count: 0,
|
||||
message,
|
||||
syncedAt: null,
|
||||
...extra,
|
||||
});
|
||||
if (!accountId)
|
||||
return { cookies: [], authKind: null, state: state("not_applicable", "匿名会话,不注入账号登录态") };
|
||||
const kind = (getSiteDefinition(site.key)?.umt as { kind?: string } | undefined)?.kind;
|
||||
let acc = await readAccountCookie(this.ctx, accountId);
|
||||
if (!acc?.applicable || kind === "mteam")
|
||||
return {
|
||||
cookies: [],
|
||||
authKind: acc?.authKind ?? null,
|
||||
state: state("not_applicable", "API Key 类站点 / 账号:网页登录态与 API Key 无关,不注入;需要时在桌面里登录"),
|
||||
};
|
||||
const rt = this.opts.runtime?.(this.ctx);
|
||||
if (!acc.header && acc.authKind === "password" && rt) {
|
||||
await refreshSiteAccount(rt, accountId, { mode: "login" }).catch(() => null);
|
||||
acc = (await readAccountCookie(this.ctx, accountId)) ?? acc;
|
||||
}
|
||||
if (!acc.header)
|
||||
return { cookies: [], authKind: acc.authKind, state: state("none", "账号还没有可用的登录态(Cookie)") };
|
||||
const cookies = cookiesFromHeader(acc.header, site.base_url, Math.floor(this.now().getTime() / 1000));
|
||||
return {
|
||||
cookies,
|
||||
authKind: acc.authKind,
|
||||
state: state("injected", null, {
|
||||
source: acc.source,
|
||||
sourceAt: acc.sourceAt ? acc.sourceAt.toISOString() : null,
|
||||
count: cookies.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从该站点 / 账号的浏览器 profile 读回站点域名的 Cookie 头串(没有启用的浏览器服务或插件时 null) */
|
||||
async readProfileCookie(siteId: string, accountId: string): Promise<string | null> {
|
||||
const prow = await this.providerRow().catch(() => null);
|
||||
if (!prow) return null;
|
||||
const provider = this.providerFor(prow);
|
||||
if (!provider.getCookies) return null;
|
||||
const site = await this.liveSite(siteId);
|
||||
const list = await provider.getCookies(profileKeyFor(siteId, accountId), [site.base_url]);
|
||||
return headerFromCookies(list, site.base_url, Math.floor(this.now().getTime() / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 回写:从会话的浏览器 profile 读回站点域名的 Cookie,与凭据存储比较,有变化则更新(来源 browser)。
|
||||
* 当前页面是登录页时不回写(未登录的 Cookie 不能覆盖可能仍有效的登录态)。失败只记在会话状态里,不抛出。
|
||||
*/
|
||||
async syncCookies(id: string, why: "release" | "close" | "periodic"): Promise<{ changed: boolean } | null> {
|
||||
try {
|
||||
const row = await this.sessionRow(id);
|
||||
if (!row.account_id) return null;
|
||||
const provider = this.providers.get(row.provider_id)?.provider;
|
||||
if (!provider?.getCookies) return null;
|
||||
const rs = obj(row.recovery_state) as RecoveryState;
|
||||
if (rs.credential?.status === "not_applicable") return null;
|
||||
const at = this.now();
|
||||
const patch = async (c: Partial<BrowserCredentialState>) => {
|
||||
const cur = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
|
||||
const base: BrowserCredentialState = cur.credential ?? {
|
||||
status: "none",
|
||||
source: null,
|
||||
sourceAt: null,
|
||||
count: 0,
|
||||
message: null,
|
||||
syncedAt: null,
|
||||
};
|
||||
await this.ctx.db
|
||||
.updateTable("browser_session")
|
||||
.set({
|
||||
recovery_state: JSON.stringify({ ...cur, credential: { ...base, syncedAt: at.toISOString(), ...c } }),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
};
|
||||
// 先按上游同步当前地址(桌面里的导航不经 API),再判断是否停在登录页
|
||||
if (provider.sessionInfo(id)) {
|
||||
await provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, provider).catch(() => {});
|
||||
}
|
||||
if (provider.sessionInfo(id)?.loginPage === true) {
|
||||
await patch({});
|
||||
return { changed: false };
|
||||
}
|
||||
const site = await this.liveSite(row.site_id);
|
||||
let header: string | null;
|
||||
try {
|
||||
header = headerFromCookies(
|
||||
await provider.getCookies(row.profile_key, [site.base_url]),
|
||||
site.base_url,
|
||||
Math.floor(at.getTime() / 1000),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_implemented")
|
||||
await patch({ message: "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件),浏览器里的登录不会回写" });
|
||||
return null;
|
||||
}
|
||||
if (!header) {
|
||||
await patch({});
|
||||
return { changed: false };
|
||||
}
|
||||
const r = await writeBrowserCookie(this.ctx, row.account_id, header, at);
|
||||
await patch(
|
||||
r.changed
|
||||
? {
|
||||
source: "browser",
|
||||
sourceAt: at.toISOString(),
|
||||
...(rs.credential?.status === "expired" ? { status: "injected" as const, message: null } : {}),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
if (r.changed)
|
||||
this.ctx.logger.info(
|
||||
{ component: "browser", sessionId: id, why, count: r.count },
|
||||
"浏览器登录态已回写站点账号",
|
||||
);
|
||||
return { changed: r.changed };
|
||||
} catch (e) {
|
||||
this.ctx.logger.warn(
|
||||
{ component: "browser", sessionId: id, why, err: String((e as Error)?.message ?? e) },
|
||||
"浏览器 Cookie 回写失败",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async closeRow(row: BrowserSessionRow, reason: string, prow?: BrowserProviderRow): Promise<void> {
|
||||
const provider = prow ? this.providerFor(prow) : this.providers.get(row.provider_id)?.provider;
|
||||
// 关闭前趁上下文还在读回一次登录态(站点 / 浏览器服务停用时不再访问)
|
||||
if (provider?.sessionInfo(row.id) && reason !== "site_disabled" && reason !== "provider_disabled")
|
||||
await this.syncCookies(row.id, "close");
|
||||
if (provider?.sessionInfo(row.id)) await provider.closeSession(row.id, reason).catch(() => {});
|
||||
const at = this.now();
|
||||
await this.ctx.db.transaction().execute(async (trx) => {
|
||||
@@ -949,6 +1135,8 @@ export class BrowserService {
|
||||
});
|
||||
if (opts.to === "automation") await this.resolveWaiting(row, lease.holder);
|
||||
await this.persist(id, provider);
|
||||
// 人工在桌面 / 截图模式里登录过:交还时读回登录态写进站点账号,全站(HTTP 抓取 / RSS / 快照)生效
|
||||
await this.syncCookies(id, "release");
|
||||
return toLease(released);
|
||||
}
|
||||
|
||||
@@ -1006,9 +1194,22 @@ export class BrowserService {
|
||||
throw fromBrowserError(e);
|
||||
}
|
||||
if (opts.persist !== false) await this.persist(id, loaded.provider, opts.patch?.(out) ?? {});
|
||||
this.maybeSyncCookies(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 有活动时每 COOKIE_SYNC_INTERVAL_MS 读回一次登录态(后台,不拖慢动作;同一会话同时只跑一个) */
|
||||
private readonly syncing = new Set<string>();
|
||||
private maybeSyncCookies(row: BrowserSessionRow): void {
|
||||
if (!row.account_id || this.syncing.has(row.id)) return;
|
||||
const c = (obj(row.recovery_state) as RecoveryState).credential;
|
||||
if (c?.status === "not_applicable") return;
|
||||
const last = c?.syncedAt ? new Date(c.syncedAt).getTime() : new Date(row.created_at).getTime();
|
||||
if (this.now().getTime() - last < COOKIE_SYNC_INTERVAL_MS) return;
|
||||
this.syncing.add(row.id);
|
||||
void this.syncCookies(row.id, "periodic").finally(() => this.syncing.delete(row.id));
|
||||
}
|
||||
|
||||
/** 导航前按站点限流取令牌 */
|
||||
async limited(site: SiteRow): Promise<void> {
|
||||
await this.rateLimit(site);
|
||||
@@ -1350,7 +1551,7 @@ const REASON_TEXT: Record<string, string> = {
|
||||
challenge: "页面需要人工验证",
|
||||
two_factor: "需要二次验证或本人设备确认",
|
||||
challenge_unsolved: "自动处理页面挑战未成功",
|
||||
login_required: "登录已失效",
|
||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||
lease_expired: "人工接管超时未交还",
|
||||
agent_request: "Agent 请求人工处理",
|
||||
human_released_waiting: "人工交还时选择继续等待",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TmdbSharedCache,
|
||||
} from "@umt/adapters";
|
||||
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
|
||||
import { pgTokenBucketFor, type SiteRow, sql } from "@umt/db";
|
||||
import { pgTokenBucketFor, type SiteRow } from "@umt/db";
|
||||
import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
@@ -170,7 +170,7 @@ export class SourcesRuntime {
|
||||
if (hit && hit.sig === sig) return hit.adapter;
|
||||
const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit);
|
||||
// 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队
|
||||
const inner =
|
||||
const limiter =
|
||||
this.overrides.sharedRateLimit === false
|
||||
? createSiteRateLimiter(lo)
|
||||
: createSharedSiteRateLimiter({
|
||||
@@ -183,7 +183,6 @@ export class SourcesRuntime {
|
||||
"站点共享限流不可用,本次退回进程内限流",
|
||||
),
|
||||
});
|
||||
const limiter = countingLimiter(inner, () => this.countSiteRequest(site.id));
|
||||
const cfg = (site.config ?? {}) as { apiBaseUrl?: string };
|
||||
const baseUrl =
|
||||
def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url;
|
||||
@@ -198,41 +197,6 @@ export class SourcesRuntime {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点请求计数(site_request_count,按小时聚合):每拿到一个令牌记一次。写失败只记日志,不影响请求;
|
||||
* 顺手删 7 天前的行(每进程每小时最多一次)。
|
||||
*/
|
||||
private lastCountPrune = 0;
|
||||
countSiteRequest(siteId: string): void {
|
||||
const hour = new Date(Math.floor(this.now().getTime() / 3_600_000) * 3_600_000);
|
||||
const db = this.ctx.db;
|
||||
void sql`insert into site_request_count (site_id, hour, count) values (${siteId}, ${hour}, 1)
|
||||
on conflict (site_id, hour) do update set count = site_request_count.count + 1`
|
||||
.execute(db)
|
||||
.then(async () => {
|
||||
if (Date.now() - this.lastCountPrune < 3_600_000) return;
|
||||
this.lastCountPrune = Date.now();
|
||||
await sql`delete from site_request_count where hour < ${new Date(hour.getTime() - 7 * 86_400_000)}`.execute(db);
|
||||
})
|
||||
.catch((err) =>
|
||||
this.ctx.logger.warn({ siteId, err: { message: (err as Error).message } }, "站点请求计数写入失败"),
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近 hours 小时(含当前小时)该站点的请求次数 */
|
||||
async siteRequestCount(siteId: string, hours = 24): Promise<number> {
|
||||
const hour = Math.floor(this.now().getTime() / 3_600_000) * 3_600_000;
|
||||
const since = new Date(hour - (hours - 1) * 3_600_000);
|
||||
const r = await sql<{ n: number }>`select coalesce(sum(count), 0)::int as n from site_request_count
|
||||
where site_id = ${siteId} and hour >= ${since}`.execute(this.ctx.db);
|
||||
return Number(r.rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/** 站点访问默认:站点定义 umt.defaults 与 settings.sites 取更保守的一方 */
|
||||
async siteAccess(siteKey: string): Promise<SiteAccess> {
|
||||
return siteAccessFor(getSiteDefinition(siteKey) ?? null, await this.siteDefaults());
|
||||
}
|
||||
|
||||
/** 已建立的站点限流器(测试与诊断用) */
|
||||
limiterOf(siteId: string): SiteRateLimiter | null {
|
||||
return this.adapters.get(siteId)?.limiter ?? null;
|
||||
@@ -296,63 +260,6 @@ function sameUrl(a: string, b: string): boolean {
|
||||
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** 站点访问默认(已合并定义与设置) */
|
||||
export interface SiteAccess {
|
||||
/** 站点行没设快照周期时用 */
|
||||
snapshotIntervalSec: number;
|
||||
snapshotMaxPages: number;
|
||||
healthCheckIntervalSec: number;
|
||||
detailCacheSec: number;
|
||||
searchConcurrency: number;
|
||||
}
|
||||
|
||||
type DefinitionDefaults = {
|
||||
snapshotIntervalSec?: number;
|
||||
snapshotMaxPages?: number;
|
||||
healthCheckIntervalSec?: number;
|
||||
detailCacheSec?: number;
|
||||
searchConcurrency?: number;
|
||||
};
|
||||
|
||||
/** 周期 / 缓存取较长,页数 / 并发取较小(定义值是按站点实测给的保守下限) */
|
||||
export function siteAccessFor(def: SiteDefinition | null, s: SitesSettings): SiteAccess {
|
||||
const d = ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults ?? {}) as DefinitionDefaults;
|
||||
const max = (a: number | undefined, b: number) => (a === undefined ? b : Math.max(a, b));
|
||||
const min = (a: number | undefined, b: number) => (a === undefined ? b : Math.min(a, b));
|
||||
return {
|
||||
snapshotIntervalSec: max(d.snapshotIntervalSec, s.defaultSnapshotIntervalSec),
|
||||
snapshotMaxPages: min(d.snapshotMaxPages, s.defaultSnapshotMaxPages),
|
||||
healthCheckIntervalSec: max(d.healthCheckIntervalSec, s.defaultHealthCheckIntervalSec),
|
||||
detailCacheSec: max(d.detailCacheSec, s.defaultDetailCacheSec),
|
||||
searchConcurrency: d.searchConcurrency ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** 同站实时搜索并发(只看站点定义,同步取值;缺省 1) */
|
||||
export function siteSearchConcurrency(siteKey: string): number {
|
||||
const def = getSiteDefinition(siteKey);
|
||||
return ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults?.searchConcurrency ?? 1) as number;
|
||||
}
|
||||
|
||||
/** 包一层限流器:每发放一个令牌回调一次(计数) */
|
||||
function countingLimiter(inner: SiteRateLimiter, onGrant: () => void): SiteRateLimiter {
|
||||
return {
|
||||
get stats() {
|
||||
return inner.stats;
|
||||
},
|
||||
async acquire(signal) {
|
||||
await inner.acquire(signal);
|
||||
onGrant();
|
||||
},
|
||||
tryAcquire() {
|
||||
const ok = inner.tryAcquire();
|
||||
if (ok) onGrant();
|
||||
return ok;
|
||||
},
|
||||
penalize: (ms) => inner.penalize(ms),
|
||||
};
|
||||
}
|
||||
|
||||
type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getSiteDefinition } from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { type SourcesRuntime, siteAccessFor } from "./runtime.ts";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { archiveStalePool } from "./service/identity-decisions.ts";
|
||||
|
||||
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
|
||||
@@ -68,32 +67,24 @@ export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
|
||||
export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
|
||||
const { ctx } = rt;
|
||||
const now = rt.now();
|
||||
// 站点未设周期时用站点定义与全局默认里更长的一个(settings.sites.defaultSnapshotIntervalSec / umt.defaults)
|
||||
const defaults = await rt.siteDefaults();
|
||||
const rows = await ctx.db
|
||||
.selectFrom("site")
|
||||
.select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"])
|
||||
.where("enabled", "=", true)
|
||||
.where("snapshot_enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
const due: Array<{ id: string; interval: number }> = [];
|
||||
for (const r of rows) {
|
||||
const interval =
|
||||
r.snapshot_interval_sec ?? siteAccessFor(getSiteDefinition(r.key) ?? null, defaults).snapshotIntervalSec;
|
||||
if (!r.last_snapshot_at || r.last_snapshot_at.getTime() + interval * 1000 <= now.getTime()) {
|
||||
due.push({ id: r.id, interval });
|
||||
}
|
||||
}
|
||||
for (const s of due) {
|
||||
const slot = Math.floor(now.getTime() / 1000 / s.interval);
|
||||
// 站点未设周期时用全局默认(settings.sites.defaultSnapshotIntervalSec)
|
||||
const fallback = (await rt.siteDefaults()).defaultSnapshotIntervalSec;
|
||||
const sites = await sql<{ id: string; snapshot_interval_sec: number }>`
|
||||
select id, coalesce(snapshot_interval_sec, ${fallback}::int) as snapshot_interval_sec from site
|
||||
where enabled and snapshot_enabled and deleted_at is null
|
||||
and (last_snapshot_at is null
|
||||
or last_snapshot_at + make_interval(secs => coalesce(snapshot_interval_sec, ${fallback}::int)) <= ${now})`.execute(
|
||||
ctx.db,
|
||||
);
|
||||
for (const s of sites.rows) {
|
||||
const slot = Math.floor(now.getTime() / 1000 / s.snapshot_interval_sec);
|
||||
await ctx.engine.enqueue(
|
||||
"snapshotSite",
|
||||
{ siteId: s.id, kind: "periodic" },
|
||||
{ idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() },
|
||||
);
|
||||
}
|
||||
return due.length;
|
||||
return sites.rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
|
||||
import { toApi } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts";
|
||||
|
||||
// W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、
|
||||
// 用户数据与按天历史、健康检测、删除。凭证明文只在内存里用,不进日志、审计与错误信息。
|
||||
@@ -440,6 +441,17 @@ export async function refreshSiteAccount(
|
||||
const plain = await secrets.resolve(acc.secret_ref);
|
||||
let r = await fetchUserData(acc.auth_kind === "cookie" ? { cookie: plain } : { apikey: plain });
|
||||
if (r.kind === "no_parser") r = await viaHealth();
|
||||
if (r.kind === "auth" && acc.auth_kind === "cookie") {
|
||||
// BR-2:存的 Cookie 失效,但浏览器里可能已人工登录过 → 读回浏览器 profile 的 Cookie 试一次,有效才回写
|
||||
const alt = await cookieFromBrowser(rt.ctx, acc, plain);
|
||||
if (alt) {
|
||||
const r2 = await fetchUserData({ cookie: alt });
|
||||
if (r2.kind === "ok") {
|
||||
await writeBrowserCookie(rt.ctx, acc.id, alt, rt.now());
|
||||
return await succeed(r2.info, "存的 Cookie 已失效;浏览器里的登录态有效,已回写");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.kind === "ok") return await succeed(r.info);
|
||||
if (r.kind === "no_parser") return await succeed(null, "站点没有用户数据解析器,只校验了登录态");
|
||||
if (r.kind === "network") return await fail("network", r.message);
|
||||
@@ -889,7 +901,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/** sites.healthCheck:启用站点逐个检测(同步,走站点限流);按站点访问默认的间隔跳过刚检测过的站点 */
|
||||
/** sites.healthCheck:启用站点逐个检测(同步,走站点限流) */
|
||||
export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
|
||||
const sites = await rt.ctx.db
|
||||
.selectFrom("site")
|
||||
@@ -899,11 +911,7 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
|
||||
.where("adapter", "!=", "generic_rss")
|
||||
.execute();
|
||||
let n = 0;
|
||||
const now = rt.now().getTime();
|
||||
for (const s of sites) {
|
||||
// 站点访问默认的最短间隔内检测过(手动或定时)就跳过;留 2 分钟余量,免得和定时任务周期错开一轮
|
||||
const { healthCheckIntervalSec } = await rt.siteAccess(s.key);
|
||||
if (s.last_health_at && now - s.last_health_at.getTime() < healthCheckIntervalSec * 1000 - 120_000) continue;
|
||||
try {
|
||||
await checkSiteHealth(rt, s);
|
||||
n++;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mergeCookieHeaders, parseCookieHeader, sameCookieHeader } from "@umt/adapters";
|
||||
import type { SiteCookieSource } from "@umt/contracts";
|
||||
import type { SiteAccountRow } from "@umt/db";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
|
||||
// BR-2 凭据统一:站点账号登录态(Cookie)的单一读写入口。
|
||||
// - 读:用户填的 Cookie(auth_kind = cookie 的 secret_ref)+ 账密自动登录得到的会话 Cookie(session_secret_ref),同名以会话 Cookie 为准;
|
||||
// - 写(浏览器回写):与当前值比较,有变化才替换对应 secret,并在 user_info.cookieOrigin 记来源 browser 与时间;
|
||||
// - 来源判定不改其它写入路径:手填 / 自动登录照旧替换 secret(rotated_at 变新),比 cookieOrigin.at 新就说明已被覆盖。
|
||||
// Cookie 明文只在内存里用,不进日志、审计、错误信息与模型上下文。
|
||||
|
||||
type Ctx = Pick<ServerContext, "db" | "secrets" | "logger">;
|
||||
|
||||
export interface AccountCookie {
|
||||
accountId: string;
|
||||
siteId: string;
|
||||
authKind: SiteAccountRow["auth_kind"];
|
||||
/** API Key / passkey 账号为 false:网页登录态与这些凭据无关,不注入浏览器 */
|
||||
applicable: boolean;
|
||||
/** 合并后的 Cookie 头串;没有为 null */
|
||||
header: string | null;
|
||||
source: SiteCookieSource | null;
|
||||
sourceAt: Date | null;
|
||||
}
|
||||
|
||||
/** user_info.cookieOrigin:最近一次由浏览器回写的记录(手填 / 自动登录不写,按 secret 时间推断) */
|
||||
interface CookieOrigin {
|
||||
source: "browser";
|
||||
by: "browser";
|
||||
at: string;
|
||||
}
|
||||
|
||||
const obj = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
|
||||
/** 主 Cookie secret:账密账号 = 会话 Cookie;Cookie 账号 = 用户填的 */
|
||||
function primaryRef(acc: SiteAccountRow): string | null {
|
||||
if (acc.auth_kind === "password") return acc.session_secret_ref;
|
||||
if (acc.auth_kind === "cookie") return acc.secret_ref;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function secretTime(ctx: Ctx, ref: string | null): Promise<Date | null> {
|
||||
if (!ref) return null;
|
||||
const r = await ctx.db
|
||||
.selectFrom("secret")
|
||||
.select(["created_at", "rotated_at"])
|
||||
.where("id", "=", ref)
|
||||
.executeTakeFirst();
|
||||
if (!r) return null;
|
||||
return new Date(r.rotated_at ?? r.created_at);
|
||||
}
|
||||
|
||||
/** 来源判定:cookieOrigin 记的时间不早于 secret 最近写入(留 5 秒余量)→ 浏览器回写;否则手填 / 自动登录 */
|
||||
async function originOf(ctx: Ctx, acc: SiteAccountRow): Promise<{ source: SiteCookieSource | null; at: Date | null }> {
|
||||
const ref = primaryRef(acc);
|
||||
if (!ref) return { source: null, at: null };
|
||||
const written = await secretTime(ctx, ref);
|
||||
const o = obj(obj(acc.user_info).cookieOrigin);
|
||||
if (o.source === "browser" && typeof o.at === "string") {
|
||||
const at = new Date(o.at);
|
||||
if (!written || at.getTime() + 5_000 >= written.getTime()) return { source: "browser", at };
|
||||
}
|
||||
return { source: acc.auth_kind === "cookie" ? "manual" : "login", at: written };
|
||||
}
|
||||
|
||||
/** 读站点账号当前登录态(Cookie)。账号不存在返回 null */
|
||||
export async function readAccountCookie(ctx: Ctx, accountId: string): Promise<AccountCookie | null> {
|
||||
const acc = await ctx.db.selectFrom("site_account").selectAll().where("id", "=", accountId).executeTakeFirst();
|
||||
if (!acc) return null;
|
||||
const base = { accountId: acc.id, siteId: acc.site_id, authKind: acc.auth_kind };
|
||||
if (acc.auth_kind !== "cookie" && acc.auth_kind !== "password")
|
||||
return { ...base, applicable: false, header: null, source: null, sourceAt: null };
|
||||
const user = acc.auth_kind === "cookie" && acc.secret_ref ? await ctx.secrets.resolve(acc.secret_ref) : null;
|
||||
const session = acc.session_secret_ref ? await ctx.secrets.resolve(acc.session_secret_ref) : null;
|
||||
const header = mergeCookieHeaders(user, session);
|
||||
const o = header ? await originOf(ctx, acc) : { source: null, at: null };
|
||||
return { ...base, applicable: true, header, source: o.source, sourceAt: o.at };
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器回写:与当前 Cookie 比较(忽略顺序),有变化才写。
|
||||
* Cookie 账号替换 secret_ref;账密账号替换(或新建)会话 Cookie。失效状态的账号回到 unknown(下次抓取 / 健康检测再验证)。
|
||||
* 返回是否写入。调用方负责只传该站域名的 Cookie,并确认浏览器当前不在登录页。
|
||||
*/
|
||||
export async function writeBrowserCookie(
|
||||
ctx: Ctx,
|
||||
accountId: string,
|
||||
header: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ changed: boolean; count: number }> {
|
||||
const count = parseCookieHeader(header).size;
|
||||
if (!count) return { changed: false, count: 0 };
|
||||
const acc = await ctx.db.selectFrom("site_account").selectAll().where("id", "=", accountId).executeTakeFirst();
|
||||
if (!acc || (acc.auth_kind !== "cookie" && acc.auth_kind !== "password")) return { changed: false, count };
|
||||
const cur = await readAccountCookie(ctx, accountId);
|
||||
if (cur && sameCookieHeader(cur.header, header)) return { changed: false, count };
|
||||
let sessionRef = acc.session_secret_ref;
|
||||
if (acc.auth_kind === "cookie") {
|
||||
if (!acc.secret_ref) return { changed: false, count };
|
||||
await ctx.secrets.replace(acc.secret_ref, header);
|
||||
// 旧会话 Cookie 会在合并时覆盖同名项,回写后以浏览器为准
|
||||
if (sessionRef) await ctx.secrets.replace(sessionRef, header);
|
||||
} else if (sessionRef) {
|
||||
await ctx.secrets.replace(sessionRef, header);
|
||||
} else {
|
||||
sessionRef = await ctx.secrets.create({
|
||||
purpose: "site_cookie",
|
||||
plaintext: header,
|
||||
label: `site_account:${acc.site_id}:${acc.label}:session:${crypto.randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
}
|
||||
const origin: CookieOrigin = { source: "browser", by: "browser", at: now.toISOString() };
|
||||
const stale = acc.status === "expired" || acc.status === "invalid";
|
||||
await ctx.db
|
||||
.updateTable("site_account")
|
||||
.set({
|
||||
user_info: JSON.stringify({ ...obj(acc.user_info), cookieOrigin: origin }),
|
||||
session_secret_ref: sessionRef,
|
||||
...(stale ? { status: "unknown" as const, last_error: null } : {}),
|
||||
updated_at: now,
|
||||
})
|
||||
.where("id", "=", acc.id)
|
||||
.execute();
|
||||
ctx.logger.info(
|
||||
{ component: "credentials", accountId, count, updatedBy: "browser" },
|
||||
"站点账号 Cookie 已从浏览器回写",
|
||||
);
|
||||
return { changed: true, count };
|
||||
}
|
||||
|
||||
// ---------- 浏览器读回钩子(浏览器模块注册;sources-identity 不直接依赖浏览器模块) ----------
|
||||
|
||||
/** 从该账号的浏览器 profile 读回站点域名的 Cookie 头串;没有浏览器服务 / 插件时返回 null */
|
||||
export type BrowserCookieReader = (siteId: string, accountId: string) => Promise<string | null>;
|
||||
|
||||
const readers = new WeakMap<object, BrowserCookieReader>();
|
||||
|
||||
export function registerBrowserCookieReader(ctx: object, reader: BrowserCookieReader): void {
|
||||
readers.set(ctx, reader);
|
||||
}
|
||||
|
||||
/** refresh-login 发现 Cookie 失效时用:浏览器 profile 里的 Cookie(与存储的相同或读不到返回 null) */
|
||||
export async function cookieFromBrowser(
|
||||
ctx: Ctx,
|
||||
acc: Pick<SiteAccountRow, "id" | "site_id">,
|
||||
current: string | null,
|
||||
): Promise<string | null> {
|
||||
const reader = readers.get(ctx);
|
||||
if (!reader) return null;
|
||||
const header = await reader(acc.site_id, acc.id).catch(() => null);
|
||||
if (!header || sameCookieHeader(header, current)) return null;
|
||||
return header;
|
||||
}
|
||||
@@ -119,11 +119,7 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
|
||||
return { status: "skipped", reason: (e as Error).message, ...none };
|
||||
}
|
||||
const cfgPages = Number((site.config as { snapshotPages?: number })?.snapshotPages) || 1;
|
||||
let maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages));
|
||||
// 定时 / 手动快照受站点访问默认的页数上限约束(补漏有自己的页数预算,实时补查只抓 1 页)
|
||||
if (input.kind === "periodic" || input.kind === "manual") {
|
||||
maxPages = Math.min(maxPages, (await rt.siteAccess(site.key)).snapshotMaxPages);
|
||||
}
|
||||
const maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages));
|
||||
const snap = await db
|
||||
.insertInto("site_snapshot")
|
||||
.values({
|
||||
@@ -215,14 +211,9 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
|
||||
if (!rel) return "not_found";
|
||||
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
||||
if (!site?.enabled) return "site_disabled";
|
||||
// 详情缓存:站点访问默认的缓存期内抓过就不再请求站点(列表快照会持续更新做种 / 促销)
|
||||
const { detailCacheSec } = await rt.siteAccess(site.key);
|
||||
if (rel.detail_fetched_at && rt.now().getTime() - rel.detail_fetched_at.getTime() < detailCacheSec * 1000) {
|
||||
return null;
|
||||
}
|
||||
let detail: SiteRelease;
|
||||
try {
|
||||
detail = mergeDetailOverList(await rt.siteAdapter(site).detail(rel.site_release_id), rel);
|
||||
detail = await rt.siteAdapter(site).detail(rel.site_release_id);
|
||||
} catch (e) {
|
||||
if (e instanceof SiteError) {
|
||||
if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e));
|
||||
@@ -235,47 +226,7 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
|
||||
}
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await upsertSiteRelease(rt, trx, site, detail);
|
||||
const keepPromotion = detail.promotion.type === "unknown";
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
.set({
|
||||
detail_fetched_at: rt.now(),
|
||||
// 详情页没解析出促销时保留列表快照的促销,不用"未知"覆盖
|
||||
...(keepPromotion
|
||||
? { promotion: jsonb(rel.promotion ?? {}), promotion_expires_at: rel.promotion_expires_at }
|
||||
: {}),
|
||||
})
|
||||
.where("id", "=", srId)
|
||||
.execute();
|
||||
await trx.updateTable("source_release").set({ detail_fetched_at: rt.now() }).where("id", "=", srId).execute();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页缺的字段沿用列表快照已有的值:有些站点(如 HHClub 的 div 详情页)详情里没有做种 / 完成数 / 分类名,
|
||||
* upsert 时 seeders / leechers 与 raw._site 会被整体覆盖,不能用 null 冲掉列表数据。
|
||||
*/
|
||||
export function mergeDetailOverList(
|
||||
detail: SiteRelease,
|
||||
rel: {
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
category_raw: string | null;
|
||||
tags_raw: string[] | null;
|
||||
raw: unknown;
|
||||
},
|
||||
): SiteRelease {
|
||||
const prev = ((rel.raw ?? {}) as { _site?: Record<string, unknown> })._site ?? {};
|
||||
return {
|
||||
...detail,
|
||||
seeders: detail.seeders ?? rel.seeders,
|
||||
leechers: detail.leechers ?? rel.leechers,
|
||||
snatched: detail.snatched ?? (typeof prev.snatched === "number" ? prev.snatched : null),
|
||||
category: {
|
||||
id: detail.category.id ?? rel.category_raw,
|
||||
name: detail.category.name ?? (typeof prev.categoryName === "string" ? prev.categoryName : null),
|
||||
},
|
||||
hr: detail.hr === "unknown" && typeof prev.hr === "boolean" ? prev.hr : detail.hr,
|
||||
tags: detail.tags.length ? detail.tags : (rel.tags_raw ?? []),
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { type SiteRow, sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { decodeCursor, page, toApi } from "../rows.ts";
|
||||
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts";
|
||||
|
||||
// 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查,
|
||||
@@ -20,15 +20,13 @@ const LIVE_FAIL_BACKOFF_MS = 60_000;
|
||||
interface LiveState {
|
||||
inflight: Map<string, Promise<{ error: string | null }>>;
|
||||
failedUntil: Map<string, { until: number; error: string }>;
|
||||
/** 按站点串行的实时搜索(站点访问默认 searchConcurrency = 1 时) */
|
||||
siteChain: Map<string, Promise<unknown>>;
|
||||
}
|
||||
|
||||
const liveStates = new WeakMap<SourcesRuntime, LiveState>();
|
||||
function liveState(rt: SourcesRuntime): LiveState {
|
||||
let s = liveStates.get(rt);
|
||||
if (!s) {
|
||||
s = { inflight: new Map(), failedUntil: new Map(), siteChain: new Map() };
|
||||
s = { inflight: new Map(), failedUntil: new Map() };
|
||||
liveStates.set(rt, s);
|
||||
}
|
||||
return s;
|
||||
@@ -46,22 +44,14 @@ export async function liveSearch(
|
||||
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
|
||||
const running = st.inflight.get(key);
|
||||
if (running) return { ...(await running), coalesced: true };
|
||||
const searchConcurrency = siteSearchConcurrency(site.key);
|
||||
const prev = searchConcurrency <= 1 ? st.siteChain.get(site.id) : undefined;
|
||||
const p = (async () => {
|
||||
// 同站不同关键词的实时搜索排队执行(不并发打站点)
|
||||
if (prev) await prev.catch(() => {});
|
||||
const r = await snapshotSite(rt, { siteId: site.id, kind: "live_search", query: q, maxPages: 1 });
|
||||
const error = r.status === "failed" || r.status === "skipped" ? (r.error ?? r.reason ?? "failed") : null;
|
||||
if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error });
|
||||
else st.failedUntil.delete(key);
|
||||
return { error };
|
||||
})().finally(() => {
|
||||
st.inflight.delete(key);
|
||||
if (st.siteChain.get(site.id) === p) st.siteChain.delete(site.id);
|
||||
});
|
||||
})().finally(() => st.inflight.delete(key));
|
||||
st.inflight.set(key, p);
|
||||
if (searchConcurrency <= 1) st.siteChain.set(site.id, p);
|
||||
return { ...(await p), coalesced: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -175,15 +175,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
.where("key", "=", `site:${siteId}`)
|
||||
.executeTakeFirst();
|
||||
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
|
||||
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
|
||||
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
|
||||
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
|
||||
expect(stats.status).toBe(200);
|
||||
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
|
||||
expect(stats.body.data.hourly).toHaveLength(24);
|
||||
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
|
||||
});
|
||||
|
||||
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
|
||||
@@ -228,13 +219,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
expect(d.body.data.refreshError).toBeNull();
|
||||
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
|
||||
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
|
||||
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
|
||||
const before = mock.requests.length;
|
||||
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(cached.body.data.refreshError).toBeNull();
|
||||
expect(mock.requests.length).toBe(before);
|
||||
// 缓存过期后才重新抓
|
||||
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
|
||||
mock.setLoggedIn(false);
|
||||
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
|
||||
expect(d2.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { sameCookieHeader } from "@umt/adapters";
|
||||
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readAccountCookie,
|
||||
registerBrowserCookieReader,
|
||||
writeBrowserCookie,
|
||||
} from "../../src/modules/sources-identity/service/credentials.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-2 凭据单一入口(mock NexusPHP 登录态判定):来源判定(手填 / 自动登录 / 浏览器回写)、回写只在有变化时写,
|
||||
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回试一次,有效才回写。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 响应体断言
|
||||
const json = async (r: Response): Promise<any> => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
|
||||
return body.data;
|
||||
};
|
||||
|
||||
describe.skipIf(!up)("BR-2 站点账号 Cookie 单一入口", () => {
|
||||
let h: Harness;
|
||||
let mock: MockNexusPhpSite;
|
||||
let siteId: string;
|
||||
let accountId: string;
|
||||
let browserCookie: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = await createMockNexusPhpSite({ username: "alice", password: "s3cret-pw" });
|
||||
h = await createHarness("br2cred");
|
||||
// 测试用 secret 替身不记 rotated_at:补上(来源判定依赖它)
|
||||
const replace = h.secrets.replace.bind(h.secrets);
|
||||
h.secrets.replace = async (ref, plaintext) => {
|
||||
await replace(ref, plaintext);
|
||||
await h.db.updateTable("secret").set({ rotated_at: new Date() }).where("id", "=", ref).execute();
|
||||
};
|
||||
const site = await json(
|
||||
await h.req("/sites", {
|
||||
body: {
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
baseUrl: `${mock.baseUrl}/`,
|
||||
rateLimit: { minIntervalMs: 0, concurrency: 10 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
siteId = site.id;
|
||||
registerBrowserCookieReader(h.ctx, async () => browserCookie);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
const refresh = async () =>
|
||||
(await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, accountId } }))).result;
|
||||
|
||||
it("手填 Cookie → 来源 manual;浏览器回写 → browser;再手填 → manual", async () => {
|
||||
const r = await json(
|
||||
await h.req(`/sites/${siteId}/credentials`, {
|
||||
method: "PUT",
|
||||
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
|
||||
}),
|
||||
);
|
||||
accountId = r.siteAccountId;
|
||||
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual", applicable: true });
|
||||
|
||||
expect(await writeBrowserCookie(h.ctx, accountId, "c_secure_pass=stale; c_secure_uid=1")).toMatchObject({
|
||||
changed: false,
|
||||
});
|
||||
const w = await writeBrowserCookie(h.ctx, accountId, "c_secure_uid=1; c_secure_pass=from-browser");
|
||||
expect(w).toMatchObject({ changed: true, count: 2 });
|
||||
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({
|
||||
source: "browser",
|
||||
header: "c_secure_pass=from-browser; c_secure_uid=1",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await json(
|
||||
await h.req(`/sites/${siteId}/credentials`, {
|
||||
method: "PUT",
|
||||
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
|
||||
}),
|
||||
);
|
||||
// cookieOrigin 记的时间比 secret 最近写入早 5 秒以上才判手填;测试里直接把回写时间拨早
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({
|
||||
user_info: JSON.stringify({ cookieOrigin: { source: "browser", by: "browser", at: "2020-01-01T00:00:00Z" } }),
|
||||
})
|
||||
.where("id", "=", accountId)
|
||||
.execute();
|
||||
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual" });
|
||||
});
|
||||
|
||||
it("refresh-login:存的 Cookie 失效,浏览器里有有效登录态 → 验证通过后回写,账号 active", async () => {
|
||||
browserCookie = "c_secure_uid=1; c_secure_pass=also-stale";
|
||||
const bad = await refresh();
|
||||
expect(bad).toMatchObject({ ok: false, status: "expired" });
|
||||
|
||||
browserCookie = mock.cookie;
|
||||
const ok = await refresh();
|
||||
expect(ok).toMatchObject({ ok: true, status: "active" });
|
||||
const acc = await readAccountCookie(h.ctx, accountId);
|
||||
expect(acc).toMatchObject({ source: "browser" });
|
||||
expect(acc!.header).toContain("mock-session-secret");
|
||||
// 之后 HTTP 抓取取凭证走同一存储
|
||||
const used = await h.rt["siteSecrets"](siteId).resolve({ siteId, kind: "cookie" });
|
||||
expect(sameCookieHeader(used, acc!.header)).toBe(true);
|
||||
});
|
||||
|
||||
it("API Key 账号不适用", async () => {
|
||||
const r = await json(
|
||||
await h.req(`/sites/${siteId}/credentials`, {
|
||||
method: "PUT",
|
||||
body: { kind: "api_key", label: "k", apiKey: "k-123" },
|
||||
}),
|
||||
);
|
||||
expect(await readAccountCookie(h.ctx, r.siteAccountId)).toMatchObject({ applicable: false, header: null });
|
||||
expect(await writeBrowserCookie(h.ctx, r.siteAccountId, "a=1")).toMatchObject({ changed: false });
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { SiteRelease } from "@umt/adapters";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeDetailOverList } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
|
||||
// HHClub 2026-09-29 实测:div 详情页没有做种 / 完成数 / 分类名,刷新详情后这些列被 null 冲掉。
|
||||
describe("详情刷新沿用列表字段", () => {
|
||||
const detail: SiteRelease = {
|
||||
siteId: "hhanclub",
|
||||
siteReleaseId: "91001",
|
||||
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
|
||||
category: { id: "412", name: null },
|
||||
sizeBytes: 194385019,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
snatched: null,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
hr: "unknown",
|
||||
uploadedAt: "2026-09-29T06:07:03.000Z",
|
||||
detailUrl: "https://hhanclub.net/details.php?id=91001",
|
||||
downloadUrl: "https://hhanclub.net/download.php?id=91001",
|
||||
tags: [],
|
||||
raw: {},
|
||||
observedAt: "2026-09-29T06:19:28.000Z",
|
||||
};
|
||||
const rel = {
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
category_raw: "412",
|
||||
tags_raw: ["官方"],
|
||||
raw: { _site: { hr: false, snatched: 0, categoryName: "短剧" } },
|
||||
};
|
||||
|
||||
it("详情缺的做种 / 下载 / 完成 / 分类名 / H&R / 标签取列表快照值", () => {
|
||||
expect(mergeDetailOverList(detail, rel)).toMatchObject({
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
snatched: 0,
|
||||
category: { id: "412", name: "短剧" },
|
||||
hr: false,
|
||||
tags: ["官方"],
|
||||
});
|
||||
});
|
||||
|
||||
it("详情有值时以详情为准", () => {
|
||||
const m = mergeDetailOverList({ ...detail, seeders: 9, hr: true, category: { id: "402", name: "电视剧" } }, rel);
|
||||
expect(m).toMatchObject({ seeders: 9, hr: true, category: { id: "402", name: "电视剧" } });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type AgentRunEvent,
|
||||
type AgentRunMessage,
|
||||
type AgentRunStatus,
|
||||
type AgentTrigger,
|
||||
Approval,
|
||||
type ApprovalStatus,
|
||||
AuditEvent,
|
||||
@@ -119,21 +120,38 @@ function useAgentMockGate(): boolean {
|
||||
|
||||
export const agentKeys = {
|
||||
all: ["agent"] as const,
|
||||
runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
|
||||
runs: (status?: AgentRunStatus, trigger?: AgentTrigger) =>
|
||||
["agent", "list", status ?? "all", trigger ?? "any"] as const,
|
||||
run: (id: string) => ["agent", "detail", id] as const,
|
||||
};
|
||||
|
||||
const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
|
||||
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
||||
|
||||
export function useAgentRuns(status?: AgentRunStatus) {
|
||||
/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */
|
||||
export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
|
||||
user: "手动",
|
||||
schedule: "巡检",
|
||||
event: "事件",
|
||||
subagent: "子会话",
|
||||
};
|
||||
|
||||
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
|
||||
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) {
|
||||
const mock = useAgentMockGate();
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...agentKeys.runs(status), mock],
|
||||
queryKey: [...agentKeys.runs(status, trigger), mock],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null };
|
||||
return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal });
|
||||
if (mock)
|
||||
return {
|
||||
items: MOCK_RUNS().filter((r) => (!status || r.status === status) && (!trigger || r.trigger === trigger)),
|
||||
nextCursor: null,
|
||||
};
|
||||
return apiParsed(RunPage, AGENT_BASE, {
|
||||
params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }),
|
||||
signal,
|
||||
});
|
||||
},
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
refetchInterval: (q) =>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SiteHealthResult,
|
||||
SiteListItem,
|
||||
SiteRefreshLoginAccepted,
|
||||
SiteRequestStats,
|
||||
SiteSnapshot,
|
||||
type SiteUpdate,
|
||||
SiteUserDataPoint,
|
||||
@@ -34,7 +33,6 @@ export const siteKeys = {
|
||||
one: (id: string) => ["sites", "one", id] as const,
|
||||
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
|
||||
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
|
||||
requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const,
|
||||
definitions: () => ["sites", "definitions"] as const,
|
||||
userData: (siteId: string, accountId?: string, days = 30) =>
|
||||
["sites", "user-data", siteId, accountId ?? "first", days] as const,
|
||||
@@ -90,28 +88,6 @@ export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 最近 24 小时对该站的请求次数与生效的访问默认(每分钟刷新) */
|
||||
export function useSiteRequestStats(siteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.requestStats(siteId ?? ""),
|
||||
enabled: !!siteId,
|
||||
refetchInterval: 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(SiteRequestStats, "/api/v1/sites/:id/request-stats", { pathParams: { id: siteId ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */
|
||||
export function accessSummary(s: SiteRequestStats): string {
|
||||
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
|
||||
const parts = [
|
||||
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
|
||||
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
|
||||
];
|
||||
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 启用 / 停用站点 */
|
||||
export function useToggleSite() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -51,6 +51,7 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
|
||||
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||
@@ -148,6 +149,38 @@ export function authDenial(call: ToolCallView): string | null {
|
||||
/** 被服务端拒绝(白名单拦截或授权校验失败) */
|
||||
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
|
||||
|
||||
/**
|
||||
* 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段;
|
||||
* 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。
|
||||
*/
|
||||
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
|
||||
const raw = call.outputSummary?.trim();
|
||||
if (!raw) return call.status === "running" ? "等待结果…" : null;
|
||||
const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t);
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch {
|
||||
return clip(raw.replace(/\s+/g, " "));
|
||||
}
|
||||
if (Array.isArray(v)) return `${v.length} 条`;
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
const err = o.error as { message?: unknown; code?: unknown } | undefined;
|
||||
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
|
||||
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
|
||||
for (const k of ["items", "results", "releases", "candidates"])
|
||||
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`;
|
||||
const simple = Object.entries(o)
|
||||
.filter(([, x]) => ["string", "number", "boolean"].includes(typeof x))
|
||||
.slice(0, 3)
|
||||
.map(([k, x]) => `${k}: ${String(x)}`);
|
||||
if (simple.length) return clip(simple.join(" · "));
|
||||
return `${Object.keys(o).length} 个字段`;
|
||||
}
|
||||
return clip(String(v));
|
||||
}
|
||||
|
||||
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
||||
export function ToolCallCard({
|
||||
call,
|
||||
@@ -198,7 +231,7 @@ export function ToolCallCard({
|
||||
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
|
||||
/>
|
||||
}
|
||||
summary={call.outputSummary ?? undefined}
|
||||
summary={toolSummary(call) ?? undefined}
|
||||
>
|
||||
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
|
||||
{call.outputSummary ? (
|
||||
@@ -384,7 +417,112 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */
|
||||
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头;
|
||||
// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ──
|
||||
|
||||
export type ConvEntry = {
|
||||
key: string;
|
||||
at: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
/** 可并入"活动"折叠(已结束的思考 / 工具调用) */
|
||||
foldable: boolean;
|
||||
/** 折叠摘要里的名字(能力名 / "思考") */
|
||||
label?: string;
|
||||
failed?: boolean;
|
||||
node: ReactNode;
|
||||
};
|
||||
export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] };
|
||||
export type ConvBlock =
|
||||
| { kind: "single"; entry: ConvEntry }
|
||||
| { kind: "turn"; key: string; at: string; parts: ConvPart[] };
|
||||
|
||||
function foldRuns(parts: ConvPart[], min: number): ConvPart[] {
|
||||
const out: ConvPart[] = [];
|
||||
let run: ConvEntry[] = [];
|
||||
const flush = () => {
|
||||
if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run });
|
||||
else for (const e of run) out.push({ kind: "item", entry: e });
|
||||
run = [];
|
||||
};
|
||||
for (const p of parts) {
|
||||
if (p.kind === "item" && p.entry.foldable) run.push(p.entry);
|
||||
else {
|
||||
flush();
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */
|
||||
export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] {
|
||||
const out: ConvBlock[] = [];
|
||||
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
|
||||
for (const e of entries) {
|
||||
if (e.role !== "assistant") {
|
||||
turn = null;
|
||||
out.push({ kind: "single", entry: e });
|
||||
continue;
|
||||
}
|
||||
if (!turn) {
|
||||
turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] };
|
||||
out.push(turn);
|
||||
}
|
||||
turn.parts.push({ kind: "item", entry: e });
|
||||
}
|
||||
for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold);
|
||||
return out;
|
||||
}
|
||||
|
||||
function clockText(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const sameDay = new Date().toDateString() === d.toDateString();
|
||||
return sameDay
|
||||
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
|
||||
: formatDateTime(iso);
|
||||
}
|
||||
|
||||
/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */
|
||||
function ActivityFold({ entries }: { entries: ConvEntry[] }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const tools = entries.filter((e) => e.label !== "思考").length;
|
||||
const thoughts = entries.length - tools;
|
||||
const failed = entries.filter((e) => e.failed).length;
|
||||
const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))];
|
||||
return (
|
||||
<div className="w-full min-w-0 rounded-xl border border-border/60 bg-foreground/[0.015]">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 text-foreground/85 tabular-nums">
|
||||
{tools ? `${tools} 次工具调用` : ""}
|
||||
{tools && thoughts ? "," : ""}
|
||||
{thoughts ? `思考 ${thoughts} 段` : ""}
|
||||
</span>
|
||||
{failed ? <span className="shrink-0 text-destructive tabular-nums">失败 {failed}</span> : null}
|
||||
<span className="min-w-0 flex-1 truncate">{names.slice(0, 4).join(" → ")}</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className="flex flex-col gap-2 border-t border-border/50 p-2">
|
||||
{entries.map((e) => (
|
||||
<div key={e.key}>{e.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */
|
||||
export function AgentConversation({
|
||||
run,
|
||||
live,
|
||||
@@ -413,16 +551,28 @@ export function AgentConversation({
|
||||
const lastIndex = live.items.length - 1;
|
||||
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
|
||||
|
||||
type Row = { key: string; at: string; node: ReactNode };
|
||||
const rows: Row[] = live.items.map((it, idx) => {
|
||||
const entries: ConvEntry[] = [];
|
||||
live.items.forEach((it, idx) => {
|
||||
const streaming = active && idx === lastIndex;
|
||||
const anim = fresh(idx);
|
||||
let node: ReactNode = null;
|
||||
const wrap = (node: ReactNode) =>
|
||||
anim ? (
|
||||
<div className="animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none">
|
||||
{node}
|
||||
</div>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
switch (it.kind) {
|
||||
case "text":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim}>
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(
|
||||
<StreamingResponse
|
||||
className="max-w-[72ch]"
|
||||
status={streaming ? "streaming" : "complete"}
|
||||
copyText={it.text}
|
||||
announce={false}
|
||||
@@ -430,67 +580,85 @@ export function AgentConversation({
|
||||
>
|
||||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||||
{streaming ? it.text : <Markdown text={it.text} />}
|
||||
</StreamingResponse>
|
||||
</AssistantRow>
|
||||
);
|
||||
</StreamingResponse>,
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "thinking":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ThinkingBlock text={it.text} streaming={streaming} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: !streaming,
|
||||
label: "思考",
|
||||
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
|
||||
});
|
||||
break;
|
||||
case "tool":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
case "tool": {
|
||||
const denied = isDenied(it.call);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.call.startedAt,
|
||||
role: "assistant",
|
||||
foldable: !denied && it.call.status !== "running" && idx !== lastIndex,
|
||||
label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId,
|
||||
failed: it.call.status === "failed",
|
||||
node: wrap(<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
node = (
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "system",
|
||||
foldable: false,
|
||||
node: (
|
||||
<MessageMarker>
|
||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||||
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
||||
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
||||
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
|
||||
</MessageMarker>
|
||||
);
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "options":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
|
||||
});
|
||||
break;
|
||||
case "approval":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
|
||||
});
|
||||
break;
|
||||
case "end":
|
||||
node = it.ok ? null : <EndNotice item={it} />;
|
||||
if (!it.ok)
|
||||
entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: <EndNotice item={it} /> });
|
||||
break;
|
||||
}
|
||||
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
|
||||
});
|
||||
for (const m of userMessages)
|
||||
rows.push({
|
||||
entries.push({
|
||||
key: `u${m.at}`,
|
||||
at: m.at,
|
||||
role: "user",
|
||||
foldable: false,
|
||||
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
|
||||
});
|
||||
rows.sort((a, b) => a.at.localeCompare(b.at));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-1 py-2">
|
||||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
||||
<AssistantRow avatar={false}>
|
||||
entries.sort((a, b) => a.at.localeCompare(b.at));
|
||||
const blocks = groupConversation(entries);
|
||||
const tail = live.items[lastIndex]?.kind;
|
||||
const typing =
|
||||
active && tail !== "text" && tail !== "thinking" ? (
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting"
|
||||
@@ -499,8 +667,41 @@ export function AgentConversation({
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
</span>
|
||||
</AssistantRow>
|
||||
) : null}
|
||||
) : null;
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
const typingInTurn = typing && lastBlock?.kind === "turn";
|
||||
|
||||
return (
|
||||
<div className="space-y-5 px-1 py-2">
|
||||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||
{blocks.map((b) =>
|
||||
b.kind === "single" ? (
|
||||
<div key={b.entry.key}>{b.entry.node}</div>
|
||||
) : (
|
||||
<Message key={b.key} from="assistant" aria-label="Agent 回合">
|
||||
<MessageAvatar>
|
||||
<Bot />
|
||||
</MessageAvatar>
|
||||
<MessageContent className="gap-2">
|
||||
<MessageHeader>
|
||||
<span className="font-medium text-foreground/80">Agent</span>
|
||||
<time dateTime={b.at}>{clockText(b.at)}</time>
|
||||
</MessageHeader>
|
||||
{b.parts.map((p) =>
|
||||
p.kind === "fold" ? (
|
||||
<ActivityFold key={p.key} entries={p.entries} />
|
||||
) : (
|
||||
<div key={p.entry.key} className="w-full min-w-0">
|
||||
{p.entry.node}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{typingInTurn && b === lastBlock ? typing : null}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
),
|
||||
)}
|
||||
{typing && !typingInTurn ? <AssistantRow avatar>{typing}</AssistantRow> : null}
|
||||
{after}
|
||||
</div>
|
||||
);
|
||||
@@ -552,7 +753,16 @@ function Meter({
|
||||
}
|
||||
|
||||
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
|
||||
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
|
||||
export function BudgetBar({
|
||||
run,
|
||||
live,
|
||||
stacked = false,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
live: AgentStreamState;
|
||||
/** 侧栏里单列排 */
|
||||
stacked?: boolean;
|
||||
}) {
|
||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const end = [...live.items].reverse().find((i) => i.kind === "end");
|
||||
@@ -563,7 +773,7 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS
|
||||
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||
: undefined);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
|
||||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
|
||||
<Meter
|
||||
label="工具调用"
|
||||
used={toolCalls}
|
||||
@@ -672,6 +882,42 @@ const PLAN_STATUS_TONE = (s: string): StatusTone =>
|
||||
? "destructive"
|
||||
: "warning";
|
||||
|
||||
/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */
|
||||
export function isLongReport(text: string): boolean {
|
||||
return text.length > 600 || text.split("\n").length > 12;
|
||||
}
|
||||
|
||||
/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */
|
||||
function LongText({ long, children }: { long: boolean; children: ReactNode }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!long) return <>{children}</>;
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"relative",
|
||||
!open && "max-h-56 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
{open ? "收起" : "展开全文"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
||||
export function FinalReport({
|
||||
run,
|
||||
@@ -719,15 +965,17 @@ export function FinalReport({
|
||||
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
||||
|
||||
return (
|
||||
<section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
|
||||
<section aria-label="最终汇报" className="grid gap-3 @2xl:grid-cols-2">
|
||||
<div className="min-w-0 rounded-xl border border-dashed border-border p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<Bot aria-hidden className="size-3.5" />
|
||||
模型说的(未经核查)
|
||||
</p>
|
||||
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
|
||||
<LongText long={isLongReport(said)}>
|
||||
<StreamingResponse status="complete" copyText={said || undefined}>
|
||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||
</StreamingResponse>
|
||||
</LongText>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
|
||||
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "./agent-session-list.tsx";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentDrawerTab,
|
||||
agentShortcutLabel,
|
||||
contextFromPath,
|
||||
contextPrefix,
|
||||
lastAgentDrawerTab,
|
||||
minimizeAgentDrawer,
|
||||
openAgentDrawer,
|
||||
openAgentHistoryRun,
|
||||
restoreAgentDrawer,
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
setAgentDrawerTab,
|
||||
showAgentRunInDrawer,
|
||||
splitContextGoal,
|
||||
useAgentDrawer,
|
||||
} from "./agent-store.ts";
|
||||
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
||||
import { quickGoalsFor } from "./quick-goals.ts";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
@@ -55,18 +63,81 @@ function ContextChips({ context }: { context: AgentContext }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TABS: { value: AgentDrawerTab; label: string }[] = [
|
||||
{ value: "current", label: "当前" },
|
||||
{ value: "history", label: "历史" },
|
||||
];
|
||||
|
||||
function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Agent 视图"
|
||||
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`agent-tab-${t.value}`}
|
||||
aria-selected={tab === t.value}
|
||||
aria-controls="agent-tabpanel"
|
||||
onClick={() => setAgentDrawerTab(t.value)}
|
||||
className={cn(
|
||||
"h-7 cursor-pointer rounded-full border border-transparent px-3 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
tab === t.value && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */
|
||||
function HistoryRunView({ runId }: { runId: string }) {
|
||||
const run = useAgentRun(runId);
|
||||
const mock = agentMockEnabled();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}>
|
||||
<ArrowLeft />
|
||||
返回列表
|
||||
</Button>
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
|
||||
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
|
||||
</p>
|
||||
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
||||
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在页面打开
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||
</AgentRunOpenContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
|
||||
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。
|
||||
* 运行中可最小化到右下角小窗,流式输出继续。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||
*/
|
||||
export function AgentDrawer() {
|
||||
const { open, minimized, context, runId } = useAgentDrawer();
|
||||
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer();
|
||||
const create = useCreateAgentRun();
|
||||
const prefix = contextPrefix(context);
|
||||
const { pathname } = useLocation();
|
||||
const content = useRef<HTMLDivElement>(null);
|
||||
const history = tab === "history";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,20 +158,21 @@ export function AgentDrawer() {
|
||||
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
|
||||
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription className="break-words">
|
||||
<SheetDescription className={cn("break-words", history && "sr-only")}>
|
||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
|
||||
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
|
||||
({agentShortcutLabel()} 开关,Esc 关闭)
|
||||
</span>
|
||||
</SheetDescription>
|
||||
{context ? <ContextChips context={context} /> : null}
|
||||
{context && !history ? <ContextChips context={context} /> : null}
|
||||
<DrawerTabs tab={tab} />
|
||||
</SheetHeader>
|
||||
{runId ? (
|
||||
{runId && !history ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -112,9 +184,27 @@ export function AgentDrawer() {
|
||||
<Minus />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" />
|
||||
<div
|
||||
id="agent-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`agent-tab-${tab}`}
|
||||
className="flex min-h-0 flex-1 flex-col px-4 py-3"
|
||||
>
|
||||
{history ? (
|
||||
historyRunId ? (
|
||||
<HistoryRunView runId={historyRunId} />
|
||||
) : (
|
||||
<AgentSessionList
|
||||
remember
|
||||
activeId={runId ?? undefined}
|
||||
onOpen={openAgentHistoryRun}
|
||||
className="flex-1"
|
||||
/>
|
||||
)
|
||||
) : runId ? (
|
||||
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||
<AgentRunPanel runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||
</AgentRunOpenContext.Provider>
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
@@ -135,28 +225,29 @@ export function AgentDrawer() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{history ? null : (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-2.5">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在会话页打开
|
||||
在页面打开
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAgentDrawerTab("history")}>
|
||||
<History />
|
||||
历史会话
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
|
||||
@@ -237,7 +328,9 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||
aria-label="打开 Agent"
|
||||
title={`Agent(${agentShortcutLabel()})`}
|
||||
className={className}
|
||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
||||
onClick={() =>
|
||||
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
||||
}
|
||||
>
|
||||
<Bot />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { AgentRunEvent } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
|
||||
import {
|
||||
authDenial,
|
||||
type ConvEntry,
|
||||
groupConversation,
|
||||
isDenied,
|
||||
isLongReport,
|
||||
pairAudit,
|
||||
runPlanIds,
|
||||
toolSummary,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
|
||||
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
|
||||
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
@@ -111,3 +121,67 @@ describe("事件折叠", () => {
|
||||
expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("会话排版(AGUI-2)", () => {
|
||||
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({
|
||||
key,
|
||||
at: key,
|
||||
role,
|
||||
foldable,
|
||||
label: foldable ? "查本地库存" : undefined,
|
||||
node: null,
|
||||
});
|
||||
it("同一助手回合归为一组,用户 / 系统条目打断回合", () => {
|
||||
const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]);
|
||||
const first = blocks[0];
|
||||
expect(first?.kind === "turn" && first.parts.length).toBe(2);
|
||||
});
|
||||
it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => {
|
||||
const [t] = groupConversation([
|
||||
e("1", "assistant", true),
|
||||
e("2", "assistant", true),
|
||||
e("3", "assistant", true),
|
||||
e("4", "assistant"),
|
||||
e("5", "assistant", true),
|
||||
e("6", "assistant", true),
|
||||
]);
|
||||
expect(t?.kind).toBe("turn");
|
||||
if (t?.kind !== "turn") return;
|
||||
expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([
|
||||
"fold3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
]);
|
||||
});
|
||||
it("工具卡一行摘要", () => {
|
||||
expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…");
|
||||
expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果");
|
||||
expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时");
|
||||
expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe(
|
||||
"found: true · count: 2",
|
||||
);
|
||||
expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时");
|
||||
});
|
||||
it("长汇报判定", () => {
|
||||
expect(isLongReport("短")).toBe(false);
|
||||
expect(isLongReport("x".repeat(601))).toBe(true);
|
||||
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("会话历史筛选", () => {
|
||||
const now = new Date("2026-09-29T12:00:00Z").getTime();
|
||||
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
|
||||
it("搜索匹配目标或 ID 前缀", () => {
|
||||
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true);
|
||||
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true);
|
||||
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false);
|
||||
});
|
||||
it("时间范围按创建时间", () => {
|
||||
expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true);
|
||||
expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false);
|
||||
expect(rangeStart("", now)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -32,15 +32,18 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
|
||||
/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */
|
||||
export function RunPlan({
|
||||
plan,
|
||||
active,
|
||||
compact = false,
|
||||
fill = false,
|
||||
}: {
|
||||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||
active: boolean;
|
||||
compact?: boolean;
|
||||
/** 侧栏 / 展开的概况条里:不限高、不自动收起 */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
if (!plan?.steps.length) return null;
|
||||
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
@@ -79,8 +82,8 @@ export function RunPlan({
|
||||
title="步骤"
|
||||
items={planTodoItems(plan.steps)}
|
||||
defaultOpen
|
||||
collapseOnComplete={!active}
|
||||
maxHeightClassName="max-h-56"
|
||||
collapseOnComplete={!fill && !active}
|
||||
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
||||
import { type AgentRunStatus, EventType } from "@umt/contracts";
|
||||
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentSessionOptions,
|
||||
@@ -9,12 +10,16 @@ import {
|
||||
agentMockEnabled,
|
||||
isProposeFirst,
|
||||
isRunActive,
|
||||
type RunSnapshot,
|
||||
TRIGGER_LABEL,
|
||||
useAbortAgentRun,
|
||||
useAgentRun,
|
||||
useAppendAgentMessage,
|
||||
useCreateAgentRun,
|
||||
useRunApprovals,
|
||||
useRunInvocationAudit,
|
||||
} from "~/api/agent.ts";
|
||||
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
|
||||
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
@@ -37,9 +42,7 @@ import {
|
||||
} from "./agent-conversation.tsx";
|
||||
import { RunPlan } from "./agent-interaction.tsx";
|
||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
|
||||
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
|
||||
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
@@ -140,21 +143,160 @@ export function GoalComposer({
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
|
||||
function shortTokens(n: number | undefined) {
|
||||
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
|
||||
}
|
||||
|
||||
/** 状态 + 来源 + 时间 + 模型 */
|
||||
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={status} />
|
||||
{isProposeFirst(run) ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
||||
{agentMockEnabled() ? (
|
||||
<StatusBadge tone="warning">
|
||||
<FlaskConical className="size-3" />
|
||||
mock 数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-meta break-words text-muted-foreground">
|
||||
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
||||
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
|
||||
* 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。
|
||||
*/
|
||||
function RunOverviewBar({
|
||||
snapshot,
|
||||
status,
|
||||
live,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
snapshot: RunSnapshot;
|
||||
status: AgentRunStatus;
|
||||
live: AgentStreamState;
|
||||
active: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const r = snapshot.run;
|
||||
const steps = live.plan?.steps ?? [];
|
||||
const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
|
||||
const failed = steps.some((s) => s.status === "failed");
|
||||
const u = { ...r.usage, ...(live.usage ?? {}) };
|
||||
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<RunStatusBadge status={status} />
|
||||
{steps.length ? (
|
||||
<>
|
||||
<ListChecks
|
||||
aria-hidden
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||||
/>
|
||||
<span className="shrink-0 text-foreground tabular-nums">
|
||||
步骤 {done}/{steps.length}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-muted-foreground tabular-nums sm:inline">{usage}</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
{steps.length ? (
|
||||
<div className="mx-3 mb-2 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
||||
failed ? "bg-destructive" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${Math.round((done / steps.length) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
|
||||
<RunMeta run={r} status={status} />
|
||||
<RunPlan plan={live.plan} active={active} fill />
|
||||
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
||||
<div>
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
|
||||
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
|
||||
const create = useCreateAgentRun();
|
||||
const [proposeFirst, setProposeFirst] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={create.isPending}
|
||||
onClick={() =>
|
||||
create.mutate(
|
||||
{ goal, proposeFirst },
|
||||
{ onSuccess: (run) => onCreated(run.id), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw />
|
||||
{create.isPending ? "新建中…" : "用同目标新建"}
|
||||
</Button>
|
||||
<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。
|
||||
* compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。
|
||||
*/
|
||||
export function AgentRunPanel({
|
||||
runId,
|
||||
compact = false,
|
||||
className,
|
||||
onCreated,
|
||||
}: {
|
||||
runId: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
|
||||
onCreated?: (runId: string) => void;
|
||||
}) {
|
||||
const run = useAgentRun(runId);
|
||||
const active = isRunActive(run.data?.run.status);
|
||||
const live = useAgentStream(runId, active || !run.data);
|
||||
const abort = useAbortAgentRun();
|
||||
const append = useAppendAgentMessage();
|
||||
const navigate = useNavigate();
|
||||
const [msg, setMsg] = useState("");
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
const sseStatus = useSseStatus();
|
||||
@@ -190,13 +332,14 @@ export function AgentRunPanel({
|
||||
const { run: r, report, messages } = run.data;
|
||||
const finished = !isRunActive(r.status) || live.finished;
|
||||
const streaming = active && !live.finished;
|
||||
const proposeFirst = isProposeFirst(r);
|
||||
const shownStatus =
|
||||
const shownStatus: AgentRunStatus =
|
||||
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
||||
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
||||
(r.status === "queued" && live.events.length > 0)
|
||||
? "running"
|
||||
: r.status;
|
||||
const ended = !isRunActive(r.status);
|
||||
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
|
||||
|
||||
const send = (text: string) =>
|
||||
append.mutate(
|
||||
@@ -210,36 +353,27 @@ export function AgentRunPanel({
|
||||
},
|
||||
);
|
||||
|
||||
const column = "mx-auto w-full max-w-[88ch]";
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||||
<div className={cn("flex min-h-0 gap-4", className)}>
|
||||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatusBadge status={shownStatus} />
|
||||
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
||||
<span className="min-w-0 text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
</span>
|
||||
{agentMockEnabled() ? (
|
||||
<StatusBadge tone="warning">
|
||||
<FlaskConical className="size-3" />
|
||||
mock 数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<SubagentTree snapshot={run.data} />
|
||||
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
|
||||
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<RunOverviewBar
|
||||
snapshot={run.data}
|
||||
status={shownStatus}
|
||||
live={live}
|
||||
active={streaming || r.status === "waiting"}
|
||||
className={cn(column, !compact && "lg:hidden")}
|
||||
/>
|
||||
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
className={cn(
|
||||
column,
|
||||
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
|
||||
)}
|
||||
>
|
||||
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
|
||||
@@ -248,7 +382,10 @@ export function AgentRunPanel({
|
||||
{live.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"
|
||||
className={cn(
|
||||
column,
|
||||
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
|
||||
<Button size="xs" variant="outline" onClick={live.retry}>
|
||||
@@ -259,8 +396,9 @@ export function AgentRunPanel({
|
||||
) : null}
|
||||
|
||||
<MessageScroller
|
||||
className={cn("min-h-64 flex-1", compact && "min-h-48")}
|
||||
className={cn("min-h-48 flex-1", compact && "min-h-40")}
|
||||
viewportClassName="pr-1"
|
||||
contentClassName={column}
|
||||
busy={streaming}
|
||||
label="Agent 会话记录"
|
||||
>
|
||||
@@ -282,7 +420,7 @@ export function AgentRunPanel({
|
||||
/>
|
||||
</MessageScroller>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn(column, "shrink-0 space-y-1")}>
|
||||
<PromptInput
|
||||
value={msg}
|
||||
onValueChange={setMsg}
|
||||
@@ -294,16 +432,42 @@ export function AgentRunPanel({
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
maxLength={8000}
|
||||
aria-label="追加消息"
|
||||
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
|
||||
aria-label={ended ? "追加消息续跑" : "追加消息"}
|
||||
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"}
|
||||
submitLabel={ended ? "续跑" : "发送"}
|
||||
toolbar={
|
||||
ended ? (
|
||||
<NewFromGoal goal={r.goal} onCreated={created} />
|
||||
) : isProposeFirst(r) ? (
|
||||
<span className="px-1 text-meta text-muted-foreground">先看方案再执行</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground">
|
||||
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
||||
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
|
||||
? `${r.summary}:在会话里的卡片上处理,会话随即继续。`
|
||||
: ended
|
||||
? "续跑沿用本会话的上下文与预算。"
|
||||
: "追加的消息在本轮结束后读取。"}{" "}
|
||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
|
||||
Enter 发送,Shift+Enter 换行。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{compact ? null : (
|
||||
<aside
|
||||
aria-label="会话概况"
|
||||
className="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-[var(--shell-border)] pl-4 lg:flex"
|
||||
>
|
||||
<RunMeta run={r} status={shownStatus} />
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
||||
<SubagentTree snapshot={run.data} className="p-2.5" />
|
||||
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<BudgetBar run={r} live={live} stacked />
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmStop}
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { Bot } from "lucide-react";
|
||||
import type { AgentRunStatus, AgentTrigger } from "@umt/contracts";
|
||||
import { Bot, ChevronRight } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
|
||||
const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "running", label: RUN_STATUS_LABEL.running },
|
||||
{ value: "waiting", label: RUN_STATUS_LABEL.waiting },
|
||||
{ value: "succeeded", label: RUN_STATUS_LABEL.succeeded },
|
||||
{ value: "failed", label: RUN_STATUS_LABEL.failed },
|
||||
{ value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded },
|
||||
{ value: "cancelled", label: RUN_STATUS_LABEL.cancelled },
|
||||
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
|
||||
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
|
||||
|
||||
export type TimeRange = "" | "today" | "7d" | "30d";
|
||||
export type HistoryFilters = { q: string; status: "" | AgentRunStatus; trigger: "" | AgentTrigger; range: TimeRange };
|
||||
export const EMPTY_FILTERS: HistoryFilters = { q: "", status: "", trigger: "", range: "" };
|
||||
|
||||
const STATUS_OPTIONS = (
|
||||
["running", "waiting", "succeeded", "failed", "budget_exceeded", "cancelled"] as AgentRunStatus[]
|
||||
).map((s) => ({ value: s, label: RUN_STATUS_LABEL[s] }));
|
||||
const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({
|
||||
value: t,
|
||||
label: TRIGGER_LABEL[t],
|
||||
}));
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "7d", label: "近 7 天" },
|
||||
{ value: "30d", label: "近 30 天" },
|
||||
];
|
||||
|
||||
function usageText(r: AgentRunView) {
|
||||
/** 时间范围的起点(毫秒);不限为 null */
|
||||
export function rangeStart(range: TimeRange, now = Date.now()): number | null {
|
||||
if (range === "today") {
|
||||
const d = new Date(now);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
if (range === "7d") return now - 7 * 86_400_000;
|
||||
if (range === "30d") return now - 30 * 86_400_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */
|
||||
export function matchesHistory(
|
||||
run: Pick<AgentRunView, "id" | "goal" | "createdAt">,
|
||||
f: Pick<HistoryFilters, "q" | "range">,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const start = rangeStart(f.range, now);
|
||||
if (start !== null && new Date(run.createdAt).getTime() < start) return false;
|
||||
const q = f.q.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
|
||||
}
|
||||
|
||||
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
|
||||
const parts = [`${r.toolCallCount} 次调用`];
|
||||
if (r.usage.totalTokens !== undefined)
|
||||
parts.push(
|
||||
@@ -31,104 +69,232 @@ function usageText(r: AgentRunView) {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */
|
||||
export function AgentSessionList({
|
||||
activeId,
|
||||
status,
|
||||
onStatusChange,
|
||||
className,
|
||||
}: {
|
||||
activeId?: string;
|
||||
status: "" | AgentRunStatus;
|
||||
onStatusChange: (s: "" | AgentRunStatus) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const runs = useAgentRuns(status || undefined);
|
||||
const mock = agentMockEnabled();
|
||||
const rows = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const ALL = "__all__";
|
||||
|
||||
/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */
|
||||
function MiniSelect({
|
||||
label,
|
||||
allLabel,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
allLabel: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||
<fieldset aria-label="按状态筛选" className="scroll-bleed mb-2 flex flex-wrap gap-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
aria-pressed={status === f.value}
|
||||
onClick={() => onStatusChange(f.value)}
|
||||
className={cn(
|
||||
"h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
status === f.value && "pill-selected text-foreground",
|
||||
)}
|
||||
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={label}
|
||||
className={cn("h-7 min-w-0 flex-1 gap-1 px-2 text-meta", !value && "text-muted-foreground")}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={ALL}>{allLabel}</SelectItem>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{runs.isPending ? (
|
||||
<LoadingState rows={4} />
|
||||
) : runs.error ? (
|
||||
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Bot}
|
||||
title={status ? "没有这个状态的会话" : "还没有会话"}
|
||||
description={status ? undefined : "交代一个目标开始。任意页面都能从右下角打开 Agent。"}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r) => {
|
||||
const current = r.id === activeId;
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
|
||||
const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 };
|
||||
|
||||
function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) {
|
||||
const goal = splitContextGoal(r.goal);
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
to={`/console/agent/${r.id}${mock ? "?mock=1" : ""}`}
|
||||
aria-current={current ? "page" : undefined}
|
||||
className={cn(
|
||||
"block min-w-0 rounded-xl border border-transparent px-3 py-2 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
|
||||
current && "pill-selected",
|
||||
)}
|
||||
>
|
||||
<p className="line-clamp-2 text-caption break-words text-foreground">
|
||||
{splitContextGoal(r.goal).text}
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
|
||||
{goal.text}
|
||||
</p>
|
||||
{splitContextGoal(r.goal).context ? (
|
||||
<p className="mt-0.5 truncate font-mono text-label text-muted-foreground">
|
||||
上下文 · {splitContextGoal(r.goal).context?.split(" · ")[0]}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
{children}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<RunStatusBadge status={r.status} />
|
||||
{r.scope.proposeFirst === true ? (
|
||||
<span className="text-meta text-muted-foreground">先看方案</span>
|
||||
) : null}
|
||||
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
||||
<span className="text-meta text-muted-foreground">{TRIGGER_LABEL[r.trigger]}</span>
|
||||
{r.scope.proposeFirst === true ? <span className="text-meta text-muted-foreground">· 先看方案</span> : null}
|
||||
<time
|
||||
dateTime={r.createdAt}
|
||||
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
|
||||
className={cn("ml-auto text-meta whitespace-nowrap text-muted-foreground", current && "text-foreground/80")}
|
||||
>
|
||||
{relTime(r.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{usageText(r)}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
||||
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
|
||||
{usageText(r)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。
|
||||
* - hrefFor:会话页左栏,点一条跳到该会话页。
|
||||
* - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。
|
||||
*/
|
||||
export function AgentSessionList({
|
||||
activeId,
|
||||
hrefFor,
|
||||
onOpen,
|
||||
remember = false,
|
||||
className,
|
||||
}: {
|
||||
activeId?: string;
|
||||
hrefFor?: (id: string) => string;
|
||||
onOpen?: (id: string) => void;
|
||||
remember?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS);
|
||||
const set = (patch: Partial<HistoryFilters>) =>
|
||||
setF((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
if (remember) drawerMemory.filters = next;
|
||||
return next;
|
||||
});
|
||||
const runs = useAgentRuns(f.status || undefined, f.trigger || undefined);
|
||||
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const now = Date.now();
|
||||
const rows = loaded.filter((r) => matchesHistory(r, f, now));
|
||||
const start = rangeStart(f.range, now);
|
||||
const oldest = loaded.at(-1);
|
||||
// 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中
|
||||
const canLoadMore = !!runs.hasNextPage && !(start !== null && oldest && new Date(oldest.createdAt).getTime() < start);
|
||||
const filtered = !!(f.q.trim() || f.status || f.trigger || f.range);
|
||||
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const restored = useRef(!remember);
|
||||
useLayoutEffect(() => {
|
||||
if (restored.current || !rows.length || !scroller.current) return;
|
||||
restored.current = true;
|
||||
scroller.current.scrollTop = drawerMemory.scroll;
|
||||
});
|
||||
const { fetchNextPage, isFetchingNextPage } = runs;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver(
|
||||
(es) => {
|
||||
if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
|
||||
},
|
||||
{ root: scroller.current, rootMargin: "120px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<Input
|
||||
type="search"
|
||||
value={f.q}
|
||||
onChange={(e) => set({ q: e.target.value })}
|
||||
placeholder="搜索目标"
|
||||
aria-label="搜索会话目标"
|
||||
className="h-8 md:text-caption"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<MiniSelect
|
||||
label="按状态筛选"
|
||||
allLabel="全部状态"
|
||||
value={f.status}
|
||||
onChange={(v) => set({ status: v as HistoryFilters["status"] })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<MiniSelect
|
||||
label="按来源筛选"
|
||||
allLabel="全部来源"
|
||||
value={f.trigger}
|
||||
onChange={(v) => set({ trigger: v as HistoryFilters["trigger"] })}
|
||||
options={TRIGGER_OPTIONS}
|
||||
/>
|
||||
<MiniSelect
|
||||
label="按时间筛选"
|
||||
allLabel="不限时间"
|
||||
value={f.range}
|
||||
onChange={(v) => set({ range: v as TimeRange })}
|
||||
options={RANGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={(e) => {
|
||||
if (remember) drawerMemory.scroll = e.currentTarget.scrollTop;
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{runs.isPending ? (
|
||||
<LoadingState rows={4} />
|
||||
) : runs.error ? (
|
||||
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
|
||||
) : rows.length === 0 && !canLoadMore ? (
|
||||
<EmptyState plain icon={Bot} title={filtered ? "没有符合条件的会话" : "还没有会话,交代一个目标开始"} />
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{rows.map((r) => {
|
||||
const current = r.id === activeId;
|
||||
const cls = cn(
|
||||
"block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
|
||||
current && "pill-selected",
|
||||
);
|
||||
return (
|
||||
<li key={r.id}>
|
||||
{onOpen ? (
|
||||
<button type="button" className={cn(cls, "group")} onClick={() => onOpen(r.id)}>
|
||||
<RunRow r={r} current={current}>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-micro group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
/>
|
||||
</RunRow>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
|
||||
aria-current={current ? "page" : undefined}
|
||||
className={cls}
|
||||
>
|
||||
<RunRow r={r} current={current}>
|
||||
{null}
|
||||
</RunRow>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{runs.hasNextPage ? (
|
||||
{canLoadMore ? (
|
||||
<div ref={sentinel} className="pt-2">
|
||||
{filtered && rows.length === 0 ? (
|
||||
<p className="px-1 pb-1 text-meta text-muted-foreground">
|
||||
已加载的 {loaded.length} 条里没有匹配,继续往前找。
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full"
|
||||
disabled={runs.isFetchingNextPage}
|
||||
onClick={() => void runs.fetchNextPage()}
|
||||
className="w-full"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => void fetchNextPage()}
|
||||
>
|
||||
{runs.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
{isFetchingNextPage ? "加载中…" : "加载更早的会话"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -16,16 +16,82 @@ export type AgentContext = {
|
||||
goal?: string;
|
||||
};
|
||||
|
||||
type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null };
|
||||
/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */
|
||||
export type AgentDrawerTab = "current" | "history";
|
||||
|
||||
let state: State = { open: false, minimized: false, context: null, runId: null };
|
||||
type State = {
|
||||
open: boolean;
|
||||
minimized: boolean;
|
||||
context: AgentContext | null;
|
||||
runId: string | null;
|
||||
tab: AgentDrawerTab;
|
||||
/** 历史 Tab 里打开的会话(null = 列表) */
|
||||
historyRunId: string | null;
|
||||
};
|
||||
|
||||
const TAB_KEY = "umt-agent-drawer-tab";
|
||||
|
||||
function readTab(): AgentDrawerTab {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(TAB_KEY) === "history" ? "history" : "current";
|
||||
} catch {
|
||||
return "current";
|
||||
}
|
||||
}
|
||||
|
||||
function writeTab(tab: AgentDrawerTab) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(TAB_KEY, tab);
|
||||
} catch {
|
||||
// 存储不可用时只在本页记住
|
||||
}
|
||||
}
|
||||
|
||||
let state: State = { open: false, minimized: false, context: null, runId: null, tab: "current", historyRunId: null };
|
||||
let tabLoaded = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
||||
state = { open: true, minimized: false, context, runId: null };
|
||||
/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */
|
||||
export function lastAgentDrawerTab(): AgentDrawerTab {
|
||||
if (!tabLoaded) {
|
||||
tabLoaded = true;
|
||||
state = { ...state, tab: readTab() };
|
||||
}
|
||||
return state.tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开抽屉。页面内按钮带着对象打开时落在「当前」开始新目标;
|
||||
* 快捷键 / 悬浮按钮不指定 tab,回到上次停留的 Tab。
|
||||
*/
|
||||
export function openAgentDrawer(context: AgentContext | null = null, tab: AgentDrawerTab = "current") {
|
||||
tabLoaded = true;
|
||||
state = { ...state, open: true, minimized: false, context, runId: null, tab };
|
||||
if (tab === "current") writeTab(tab);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerTab(tab: AgentDrawerTab) {
|
||||
tabLoaded = true;
|
||||
state = { ...state, tab };
|
||||
writeTab(tab);
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 历史 Tab:在抽屉内打开一条会话(null 回到列表) */
|
||||
export function openAgentHistoryRun(runId: string | null) {
|
||||
state = { ...state, tab: "history", historyRunId: runId };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 把一条会话放到「当前」(同目标新建后、从历史里继续时) */
|
||||
export function showAgentRunInDrawer(runId: string) {
|
||||
tabLoaded = true;
|
||||
state = { ...state, open: true, minimized: false, runId, tab: "current" };
|
||||
writeTab("current");
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -40,9 +106,9 @@ export function minimizeAgentDrawer() {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** 从小窗恢复抽屉 */
|
||||
/** 从小窗恢复抽屉(小窗里的运行在「当前」) */
|
||||
export function restoreAgentDrawer() {
|
||||
state = { ...state, open: true, minimized: false };
|
||||
state = { ...state, open: true, minimized: false, tab: "current" };
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -66,7 +132,7 @@ export function isAgentShortcut(
|
||||
export function toggleAgentDrawer(pathname: string) {
|
||||
if (state.open) setAgentDrawerOpen(false);
|
||||
else if (state.minimized && state.runId) restoreAgentDrawer();
|
||||
else openAgentDrawer(contextFromPath(pathname));
|
||||
else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab());
|
||||
}
|
||||
|
||||
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
|
||||
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
@@ -20,6 +20,27 @@ export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?
|
||||
|
||||
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
|
||||
|
||||
/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */
|
||||
export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null);
|
||||
|
||||
const LINK_CLASS =
|
||||
"line-clamp-2 cursor-pointer rounded-tight text-left text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
function RunLink({ id, children }: { id: string; children: ReactNode }) {
|
||||
const open = useContext(AgentRunOpenContext);
|
||||
if (open)
|
||||
return (
|
||||
<button type="button" onClick={() => open(id)} className={LINK_CLASS}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Link to={href(id)} className={LINK_CLASS}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function budgetText(b: Record<string, number | undefined>) {
|
||||
const parts: string[] = [];
|
||||
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
|
||||
@@ -49,12 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
||||
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={href(node.id)}
|
||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{splitContextGoal(node.goal).text}
|
||||
</Link>
|
||||
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={node.status} />
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
@@ -82,7 +98,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
||||
}
|
||||
|
||||
/** 子会话树 + 父会话链接;两者都没有时返回 null */
|
||||
export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
||||
export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; className?: string }) {
|
||||
const { run, parent, children = [], familyUsage } = snapshot;
|
||||
if (!parent && children.length === 0) return null;
|
||||
const byParent = new Map<string, AgentRunNode[]>();
|
||||
@@ -93,19 +109,14 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
||||
const direct = byParent.get(run.id) ?? [];
|
||||
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
|
||||
return (
|
||||
<section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
|
||||
<section aria-label="子会话" className={cn("space-y-2 rounded-xl border border-border/70 p-3", className)}>
|
||||
{parent ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<CornerLeftUp aria-hidden className="size-3.5" />
|
||||
父会话
|
||||
</p>
|
||||
<Link
|
||||
to={href(parent.id)}
|
||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{splitContextGoal(parent.goal).text}
|
||||
</Link>
|
||||
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
||||
{budgetText(run.budget as Record<string, number | undefined>)}
|
||||
@@ -115,7 +126,7 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
||||
) : null}
|
||||
{direct.length ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<GitBranch aria-hidden className="size-3.5" />
|
||||
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
|
||||
</p>
|
||||
|
||||
@@ -245,7 +245,15 @@ function seed() {
|
||||
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);
|
||||
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 } },
|
||||
{
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
|
||||
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
|
||||
// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
|
||||
// AGUI-2:收起态压成一行(能力名 · 状态 · 耗时 · 摘要),能力 ID 与徽标移进展开区;
|
||||
// 默认一律收起(运行中也只显示一行状态);用户点开的卡片完成后保持展开(collapseOnComplete 可改)。
|
||||
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
@@ -61,8 +62,8 @@ export function ToolResult({
|
||||
badges,
|
||||
summary,
|
||||
icon,
|
||||
defaultOpen = status === "running",
|
||||
collapseOnComplete = true,
|
||||
defaultOpen = false,
|
||||
collapseOnComplete = false,
|
||||
className,
|
||||
}: ToolResultProps) {
|
||||
const id = useId();
|
||||
@@ -70,7 +71,6 @@ export function ToolResult({
|
||||
const prev = useRef(status);
|
||||
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
|
||||
useEffect(() => {
|
||||
if (prev.current !== "running" && status === "running") setOpen(true);
|
||||
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
|
||||
prev.current = status;
|
||||
}, [collapseOnComplete, setOpen, status]);
|
||||
@@ -88,28 +88,31 @@ export function ToolResult({
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-c`}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
|
||||
{icon ?? <KindIcon kind={kind} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 truncate text-caption font-medium text-foreground/90">{title}</span>
|
||||
<span className="min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
|
||||
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
|
||||
</span>
|
||||
{/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */}
|
||||
<span className="max-w-[45%] shrink-0 truncate text-caption font-medium text-foreground/90">{title}</span>
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
|
||||
<StatusIcon status={status} />
|
||||
{st.label}
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
|
||||
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
|
||||
{!open && summary ? summary : null}
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
{badges ? <div className="flex flex-wrap items-center gap-1 px-3 pb-2 pl-9">{badges}</div> : null}
|
||||
{!open && summary ? (
|
||||
<p className="-mt-1 truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{summary}</p>
|
||||
) : null}
|
||||
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="mr-1 min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
|
||||
{badges}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserController } from "@umt/contracts";
|
||||
import type { BrowserController, BrowserCredentialState } from "@umt/contracts";
|
||||
|
||||
/** 截图上的点击位置 → 浏览器视口像素坐标(按图片实际像素与显示尺寸的比例换算,四舍五入并夹在画面内) */
|
||||
export function toViewport(
|
||||
@@ -28,7 +28,7 @@ const WAITING_TEXT: Record<string, string> = {
|
||||
challenge: "页面需要人工验证",
|
||||
two_factor: "需要二次验证或本人设备确认",
|
||||
challenge_unsolved: "自动处理页面挑战未成功",
|
||||
login_required: "登录已失效",
|
||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||
lease_expired: "接管超时未交还",
|
||||
agent_request: "Agent 请求人工处理",
|
||||
human_released_waiting: "交还时选择继续等待",
|
||||
@@ -44,3 +44,27 @@ export function holderName(holder: string): string {
|
||||
const i = holder.indexOf(":");
|
||||
return i >= 0 ? holder.slice(i + 1) : holder;
|
||||
}
|
||||
|
||||
const COOKIE_SOURCE: Record<string, string> = { manual: "手填", login: "自动登录", browser: "浏览器回写" };
|
||||
|
||||
/** 会话登录态徽标(BR-2):来源(手填 / 自动登录 / 浏览器回写)与时间;没有账号或不适用时不显示 */
|
||||
export function credentialBadge(
|
||||
c: BrowserCredentialState | null | undefined,
|
||||
): { tone: "success" | "warning" | "neutral"; text: string; title: string | undefined } | null {
|
||||
if (!c || c.status === "not_applicable") return null;
|
||||
const when = c.sourceAt ? new Date(c.sourceAt).toLocaleString() : null;
|
||||
const src = c.source ? `${COOKIE_SOURCE[c.source] ?? c.source}${when ? ` · ${when}` : ""}` : null;
|
||||
const title = c.message ?? undefined;
|
||||
switch (c.status) {
|
||||
case "injected":
|
||||
return { tone: "success", text: `登录态:${src ?? "已注入"}`, title };
|
||||
case "expired":
|
||||
return { tone: "warning", text: "登录态已过期:在画面里登录一次即可全站生效", title };
|
||||
case "none":
|
||||
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次即可全站生效", title };
|
||||
case "unsupported":
|
||||
return { tone: "warning", text: "浏览器服务不支持注入登录态", title };
|
||||
default:
|
||||
return { tone: "warning", text: "登录态注入失败", title };
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { apiData, errorMessage } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { CONTROLLER_LABEL, holderName, waitingText } from "./coords.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
|
||||
import { SiteOriginView } from "./site-origin-view.tsx";
|
||||
|
||||
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
|
||||
@@ -115,6 +115,7 @@ function DesktopView({
|
||||
const mine = role === "controller";
|
||||
const busy = control.acquire.isPending || control.release.isPending;
|
||||
const waiting = waitingText(s.waitingReason);
|
||||
const cred = credentialBadge(s.credential);
|
||||
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
||||
const conn: { tone: StatusTone; text: string } = loaded
|
||||
? mine
|
||||
@@ -131,6 +132,11 @@ function DesktopView({
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
{cred ? (
|
||||
<span title={cred.title}>
|
||||
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{human && mine ? (
|
||||
<Button
|
||||
|
||||
@@ -34,7 +34,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { CONTROLLER_LABEL, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
|
||||
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
|
||||
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
|
||||
@@ -114,6 +114,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
? { tone: "success", text: "已连接" }
|
||||
: { tone: "neutral", text: "连接中" };
|
||||
const waiting = waitingText(s.waitingReason);
|
||||
const cred = credentialBadge(s.credential);
|
||||
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
||||
|
||||
const onScreenClick = (e: MouseEvent<HTMLImageElement>) => {
|
||||
@@ -149,6 +150,11 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
{cred ? (
|
||||
<span title={cred.title}>
|
||||
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
|
||||
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -27,24 +27,6 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
kind: "number",
|
||||
hint: "站点开了定时快照但没设周期时使用;5–10080",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultSnapshotMaxPages",
|
||||
label: "快照页数",
|
||||
kind: "number",
|
||||
hint: "定时和手动快照最多抓几页列表;站点定义更少时按站点;1–20",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultHealthCheckIntervalSec",
|
||||
label: "健康检测间隔(小时)",
|
||||
kind: "number",
|
||||
hint: "同一站点两次定时检测至少隔这么久;站点定义更长时按站点",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultDetailCacheSec",
|
||||
label: "详情缓存(小时)",
|
||||
kind: "number",
|
||||
hint: "期内不重复抓同一条资源详情;0 为不缓存",
|
||||
},
|
||||
{
|
||||
name: "sites.userDataMaxSeedingPages",
|
||||
label: "做种列表页数上限",
|
||||
@@ -72,9 +54,6 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
"sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency),
|
||||
"sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)),
|
||||
"sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages),
|
||||
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
|
||||
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
|
||||
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
|
||||
"sites.userAgent": s.sites.userAgent,
|
||||
"sites.proxyUrl": s.sites.proxyUrl ?? "",
|
||||
}),
|
||||
@@ -82,8 +61,6 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
const min = num(v["sites.defaultSnapshotIntervalSec"]);
|
||||
const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]);
|
||||
const concurrency = num(v["sites.defaultRateLimit.concurrency"]);
|
||||
const healthH = num(v["sites.defaultHealthCheckIntervalSec"]);
|
||||
const detailH = num(v["sites.defaultDetailCacheSec"]);
|
||||
return {
|
||||
sites: {
|
||||
defaultRateLimit: {
|
||||
@@ -93,9 +70,6 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
},
|
||||
defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60),
|
||||
userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]),
|
||||
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
|
||||
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
|
||||
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
|
||||
userAgent: str(v["sites.userAgent"]) || undefined,
|
||||
proxyUrl: str(v["sites.proxyUrl"]) || null,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SiteListItem } from "@umt/contracts";
|
||||
import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts";
|
||||
import { useSiteUserData } from "~/api/sites.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
@@ -26,7 +26,6 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
const acc = primaryAccount(site.accounts);
|
||||
const u = dAcc?.userData ?? null;
|
||||
const history = useSiteUserData(site.id, dAcc?.id, 30);
|
||||
const stats = useSiteRequestStats(site.id);
|
||||
const st = ACCOUNT_OVERALL[overallStatus(site)];
|
||||
const h = healthLine(site.lastHealth, site.lastHealthAt);
|
||||
|
||||
@@ -105,17 +104,6 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
: "未开启",
|
||||
},
|
||||
{ label: "限流", value: rateLimitText(site.rateLimit) },
|
||||
{
|
||||
label: "近 24 小时请求",
|
||||
value: stats.data ? (
|
||||
<span>
|
||||
{stats.data.last24h} 次
|
||||
<span className="block text-meta text-muted-foreground">{accessSummary(stats.data)}</span>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -41,97 +41,6 @@ export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionEx
|
||||
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
|
||||
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
|
||||
|
||||
/** 完成数(站点 raw._site.snatched);没有时 null */
|
||||
export function snatchedOf(r: Pick<SourceRelease, "raw">): number | null {
|
||||
const v = siteExtra(r).snatched;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/** 促销剩余时间:"剩 3 小时";没有截止时间或已过期返回 null */
|
||||
export function remainingLabel(iso: string | null | undefined, now = Date.now()): string | null {
|
||||
if (!iso) return null;
|
||||
const left = new Date(iso).getTime() - now;
|
||||
if (!Number.isFinite(left) || left <= 0) return null;
|
||||
if (left < 3_600_000) return `剩 ${Math.max(1, Math.round(left / 60_000))} 分钟`;
|
||||
if (left < 2 * 86_400_000) return `剩 ${Math.round(left / 3_600_000)} 小时`;
|
||||
return `剩 ${Math.round(left / 86_400_000)} 天`;
|
||||
}
|
||||
|
||||
/** 绝对时间(本地时区,精确到分钟),用在悬停提示 */
|
||||
export function absTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type ParsedLike = {
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
videoCodec?: string;
|
||||
hdr?: string[];
|
||||
releaseGroup?: { name?: string };
|
||||
subtitles?: string[];
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
seasonInferred?: boolean;
|
||||
episodes?: { start: number; end: number };
|
||||
totalEpisodes?: number;
|
||||
isSeasonPack?: boolean;
|
||||
};
|
||||
|
||||
export type SpecTagKind = "episode" | "resolution" | "source" | "codec" | "hdr" | "group" | "lang" | "site";
|
||||
export interface SpecTag {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: SpecTagKind;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* 资源规格标签:季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字,再加站点自带标签(tagsRaw)。
|
||||
* 都来自标题解析结果与标题、副标题原文,不猜;同名去重。
|
||||
*/
|
||||
export function releaseSpecTags(r: Pick<SourceRelease, "parsed" | "title" | "subtitle" | "tagsRaw">): SpecTag[] {
|
||||
const p = (r.parsed ?? {}) as ParsedLike;
|
||||
const out: SpecTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: SpecTagKind, label: string | undefined | null) => {
|
||||
const l = label?.trim();
|
||||
if (!l || seen.has(l.toLowerCase())) return;
|
||||
seen.add(l.toLowerCase());
|
||||
out.push({ key: `${kind}:${l}`, label: l, kind });
|
||||
};
|
||||
// 季号是推定的(标题里没写)就不显示季,只显示集
|
||||
const season =
|
||||
p.season !== undefined && !p.seasonInferred
|
||||
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
|
||||
: "";
|
||||
const episode = p.episodes
|
||||
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
|
||||
: "";
|
||||
const total = !episode && p.totalEpisodes ? `全 ${p.totalEpisodes} 集` : "";
|
||||
add("episode", [`${season}${episode}`, total].filter(Boolean).join(" "));
|
||||
add("resolution", p.resolution === "2160p" ? "4K" : p.resolution);
|
||||
add("source", p.source);
|
||||
add("codec", p.videoCodec);
|
||||
for (const h of p.hdr ?? []) add("hdr", h);
|
||||
add("group", p.releaseGroup?.name);
|
||||
const text = `${r.title} ${r.subtitle ?? ""}`;
|
||||
if (/国语|國語|国配|普通话|中配/.test(text)) add("lang", "国语");
|
||||
if (/粤语|粵語/.test(text)) add("lang", "粤语");
|
||||
if (
|
||||
/中字|中文字幕|简中|繁中|简繁|簡繁|双语字幕|雙語字幕|内嵌中|内封中/.test(text) ||
|
||||
(p.subtitles ?? []).some((l) => l.startsWith("zh"))
|
||||
) {
|
||||
add("lang", "中字");
|
||||
}
|
||||
for (const t of r.tagsRaw ?? []) add("site", t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 相对时间(中文);未来时间显示"x 后" */
|
||||
export function relTime(iso: string | null | undefined, now = Date.now()): string {
|
||||
if (!iso) return "—";
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Check } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
|
||||
import { inventoryFlags } from "~/api/inventory.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import {
|
||||
absTime,
|
||||
categoryLabel,
|
||||
hrOf,
|
||||
ORIGIN_LABEL,
|
||||
promotionLabel,
|
||||
releaseSpecTags,
|
||||
relTime,
|
||||
remainingLabel,
|
||||
snatchedOf,
|
||||
} from "./release-fields.ts";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
|
||||
/** 促销 / H&R 徽章(列表与详情共用) */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
|
||||
return (
|
||||
<>
|
||||
{promo ? (
|
||||
<span title={r.promotionExpiresAt ? `促销截止 ${absTime(r.promotionExpiresAt)}` : undefined}>
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
@@ -36,40 +23,6 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */
|
||||
export function SpecTags({ r }: { r: Pick<WorkbenchRelease, "parsed" | "title" | "subtitle" | "tagsRaw"> }) {
|
||||
const tags = releaseSpecTags(r);
|
||||
if (!tags.length) return null;
|
||||
return (
|
||||
<>
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t.key}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
|
||||
t.kind === "resolution" || t.kind === "hdr"
|
||||
? "border-border text-foreground"
|
||||
: "border-border/70 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 没有关联作品时的说明(明确写出,不猜) */
|
||||
export function unresolvedLabel(release: Pick<WorkbenchRelease, "resolutionStatus">): string {
|
||||
return release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
}
|
||||
|
||||
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
|
||||
export function InventoryOverlay({
|
||||
release,
|
||||
@@ -80,7 +33,17 @@ export function InventoryOverlay({
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity) return <span className="text-meta text-muted-foreground">{unresolvedLabel(release)}</span>;
|
||||
if (!release.entity) {
|
||||
const label =
|
||||
release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
return <span className="text-meta text-muted-foreground">{label}</span>;
|
||||
}
|
||||
const flags = inventoryFlags(entry, release.resolutionStatus, release.id);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
@@ -105,11 +68,6 @@ export function InventoryOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源列表行,两层:
|
||||
* 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。
|
||||
* 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。
|
||||
*/
|
||||
export function ReleaseRow({
|
||||
r,
|
||||
entry,
|
||||
@@ -128,11 +86,6 @@ export function ReleaseRow({
|
||||
}) {
|
||||
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
|
||||
const cat = categoryLabel(r);
|
||||
const snatched = snatchedOf(r);
|
||||
const timeTitle = [
|
||||
r.sourcePublishedAt ? `上传 ${absTime(r.sourcePublishedAt)}` : "上传时间未知",
|
||||
`${ORIGIN_LABEL[r.origin]}数据 ${relTime(r.dataUpdatedAt)}确认`,
|
||||
].join("\n");
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
@@ -140,68 +93,37 @@ export function ReleaseRow({
|
||||
onClick={onOpen}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"group block w-full min-w-0 cursor-pointer px-4 py-2.5 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selected && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere] group-hover:underline group-hover:underline-offset-2"
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</span>
|
||||
<SpecTags r={r} />
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<ReleaseBadges r={r} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-col gap-1 text-meta text-muted-foreground sm:flex-row sm:items-center sm:gap-4">
|
||||
<span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
|
||||
{r.subtitle ?? ""}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
|
||||
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
|
||||
{cat ?? "分类未知"}
|
||||
</span>
|
||||
<span className="sm:w-16 sm:text-right">
|
||||
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
|
||||
<ArrowUp className="h-3 w-3" aria-label="做种" />
|
||||
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
|
||||
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
{cat ? <span>{cat}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
|
||||
{snatched ?? "?"}
|
||||
</span>
|
||||
<span className="sm:w-20 sm:text-right" title={timeTitle}>
|
||||
{relTime(r.sourcePublishedAt)}
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
|
||||
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
|
||||
) : null}
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</span>
|
||||
</div>
|
||||
<InventoryLine release={r} entry={entry} loading={inventoryLoading} />
|
||||
<div className="mt-1.5">
|
||||
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */
|
||||
function InventoryLine({
|
||||
release,
|
||||
entry,
|
||||
loading,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity && release.resolutionStatus !== "ambiguous") return null;
|
||||
return (
|
||||
<div className="mt-1.5 min-w-0">
|
||||
<InventoryOverlay release={release} entry={entry} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,44 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||
import { splitContextGoal } from "~/components/agent/agent-store.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上;
|
||||
// 更窄时换成会话列顶部的可折叠概况条)。
|
||||
export default function AgentRunPage() {
|
||||
const { runId = "" } = useParams();
|
||||
const run = useAgentRun(runId);
|
||||
const mock = agentMockEnabled();
|
||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
||||
const suffix = mock ? "?mock=1" : "";
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description={run.data ? <span className="line-clamp-2 break-words">{run.data.run.goal}</span> : "加载中…"}
|
||||
description={
|
||||
run.data ? (
|
||||
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
|
||||
{splitContextGoal(run.data.run.goal).text}
|
||||
</span>
|
||||
) : (
|
||||
"加载中…"
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`/console/agent${mock ? "?mock=1" : ""}`}>
|
||||
<Link to={`/console/agent${suffix}`}>
|
||||
<ArrowLeft />
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
||||
<aside className="pg-slab hidden rounded-xl p-3 lg:block lg:h-[calc(100dvh-13rem)] lg:overflow-hidden">
|
||||
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
|
||||
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-13rem)] xl:overflow-hidden">
|
||||
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" />
|
||||
</aside>
|
||||
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
|
||||
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { FlaskConical, ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
||||
@@ -11,7 +9,6 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
export default function AgentPage() {
|
||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
||||
const create = useCreateAgentRun();
|
||||
const navigate = useNavigate();
|
||||
const mock = agentMockEnabled();
|
||||
@@ -31,11 +28,11 @@ export default function AgentPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
|
||||
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:max-h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
||||
<AgentSessionList status={status} onStatusChange={setStatus} className="h-full" />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
||||
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" />
|
||||
</div>
|
||||
<div className="order-1 space-y-4 lg:order-2">
|
||||
<div className="order-1 mx-auto w-full max-w-[88ch] space-y-3 lg:order-2">
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
|
||||
<GoalComposer
|
||||
@@ -51,15 +48,10 @@ export default function AgentPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
|
||||
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
|
||||
<p className="flex items-center gap-2 px-1 text-meta text-muted-foreground">
|
||||
<ShieldCheck aria-hidden className="size-3.5 shrink-0" />
|
||||
每次工具调用都经服务端授权校验,汇报以系统核实的结果为准。
|
||||
</p>
|
||||
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useInventory } from "~/api/inventory.ts";
|
||||
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
||||
import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
@@ -157,7 +157,6 @@ export default function SiteWorkbenchPage() {
|
||||
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
|
||||
const selected = all.find((r) => r.id === openId) ?? null;
|
||||
const freeCount = all.filter(isFree).length;
|
||||
const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined);
|
||||
|
||||
const reset = () => {
|
||||
setQInput("");
|
||||
@@ -293,14 +292,10 @@ export default function SiteWorkbenchPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!s?.enabled || snap.isPending}
|
||||
title={
|
||||
reqStats.data
|
||||
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
|
||||
: "抓取站点列表(受站点限流)"
|
||||
}
|
||||
title="手动快照(受站点限流)"
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: siteId },
|
||||
{ id: siteId, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success("快照已排队"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -394,11 +389,6 @@ export default function SiteWorkbenchPage() {
|
||||
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
|
||||
</span>
|
||||
{freeCount ? <span>其中免费 {freeCount}</span> : null}
|
||||
{reqStats.data ? (
|
||||
<span className="ml-auto" title={accessSummary(reqStats.data)}>
|
||||
近 24 小时请求站点 {reqStats.data.last24h} 次
|
||||
</span>
|
||||
) : null}
|
||||
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60">
|
||||
|
||||
@@ -250,6 +250,36 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 交互:Agent 抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)──
|
||||
{
|
||||
const label = "Agent 抽屉 → 历史 → 抽屉内打开会话";
|
||||
problems = [];
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await page.goto("about:blank");
|
||||
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
const launcher = page.locator("[data-agent-launcher]:visible").last();
|
||||
await launcher.click();
|
||||
await page.getByRole("tab", { name: "历史" }).click();
|
||||
const list = page.getByRole("navigation", { name: "Agent 会话列表" });
|
||||
await list.getByRole("button").first().click();
|
||||
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 });
|
||||
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
|
||||
await page.getByRole("button", { name: "返回列表" }).click();
|
||||
await list.waitFor({ timeout: 5_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await launcher.click();
|
||||
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected");
|
||||
if (tab !== "true") problems.push("再次打开没有停在「历史」");
|
||||
if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {});
|
||||
await page.keyboard.press("Escape");
|
||||
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
} catch (err) {
|
||||
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
||||
}
|
||||
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
|
||||
}
|
||||
|
||||
// ── 旧链接重定向 ──
|
||||
for (const r of REDIRECT_ROUTES) {
|
||||
let from = r.from;
|
||||
|
||||
@@ -35,6 +35,9 @@ COPY --from=camofox /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=camofox /app /opt/camofox
|
||||
COPY --from=camofox /root/.cache/camoufox /opt/camoufox
|
||||
COPY root/ /
|
||||
# umt-cookies 插件(BR-2:站点账号 Cookie 注入 / 从浏览器读回,访问凭据下的 /umt/sessions/:userId/cookies)
|
||||
# 上游插件加载器只认 camofox.config.json 的 plugins 清单,这里把它加进去
|
||||
RUN node -e 'const f="/opt/camofox/camofox.config.json";const c=JSON.parse(require("fs").readFileSync(f,"utf8"));c.plugins={...(c.plugins||{}),"umt-cookies":{enabled:true}};require("fs").writeFileSync(f,JSON.stringify(c,null,2))'
|
||||
|
||||
LABEL org.opencontainers.image.title="umt-browser-desktop" \
|
||||
org.opencontainers.image.description="camofox-browser 1.17.0 (headed, Wayland) on linuxserver baseimage-selkies" \
|
||||
|
||||
@@ -27,4 +27,6 @@ chmod 550 "$LABWC_DIR/autostart"
|
||||
PROFILE_ROOT="$(dirname "${CAMOFOX_PROFILE_DIR:-/root/.camofox/profiles}")"
|
||||
mkdir -p "${CAMOFOX_PROFILE_DIR:-/root/.camofox/profiles}"
|
||||
chown -R abc:abc "$PROFILE_ROOT"
|
||||
# 登录状态含站点 Cookie:目录 0700、文件 0600(旧镜像落盘的是 0644)
|
||||
chmod -R go-rwx "$PROFILE_ROOT"
|
||||
chmod 711 /root
|
||||
@@ -12,6 +12,8 @@ if [[ "${UMT_DESKTOP_IME,,}" == "fcitx5" ]]; then
|
||||
fi
|
||||
# Selkies 的手柄/摄像头 LD_PRELOAD 不给浏览器
|
||||
unset LD_PRELOAD
|
||||
# profile 落盘文件(storage-state.json 含登录 Cookie)只给运行用户读写
|
||||
umask 077
|
||||
cd /opt/camofox
|
||||
# exec:SIGTERM 直达 node,持久化插件关机落盘(docker/camofox/Dockerfile 同样的教训)
|
||||
exec s6-setuidgid abc node --max-old-space-size="${MAX_OLD_SPACE_SIZE:-128}" server.js
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* umt-cookies:UMT 给 camofox-browser 1.17.0 加的最小插件(BR-2 凭据统一,docs/runbook/browser.md §9)。
|
||||
*
|
||||
* 上游只有 `POST /sessions/:userId/cookies`(导入,要求单独的 CAMOFOX_API_KEY),没有读取端点。本插件在访问凭据
|
||||
* (CAMOFOX_ACCESS_KEY,ctx.auth())之下提供:
|
||||
* POST /umt/sessions/:userId/cookies { cookies: Cookie[] } → 写入该 profile 的 BrowserContext 并触发持久化落盘
|
||||
* GET /umt/sessions/:userId/cookies?urls=a,b → 读作用于这些地址的 Cookie(活动上下文优先,否则读落盘文件)
|
||||
* Cookie 值只在请求/响应体里,不写日志(只记个数)。
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import express from "express";
|
||||
import { getUserPersistencePaths } from "../../lib/persistence.js";
|
||||
|
||||
const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"];
|
||||
|
||||
function parseUrls(raw) {
|
||||
const out = [];
|
||||
for (const s of String(raw || "").split(",")) {
|
||||
try {
|
||||
const u = new URL(s.trim());
|
||||
if (u.protocol === "http:" || u.protocol === "https:") out.push(u);
|
||||
} catch {}
|
||||
}
|
||||
return out.slice(0, 20);
|
||||
}
|
||||
|
||||
/** 与浏览器发送规则一致的最小匹配:域名(host-only / .根域)、路径前缀、secure、未过期 */
|
||||
function matches(c, urls, nowSec) {
|
||||
if (typeof c.expires === "number" && c.expires > 0 && c.expires <= nowSec) return false;
|
||||
const d = String(c.domain || "")
|
||||
.toLowerCase()
|
||||
.replace(/^\./, "");
|
||||
return urls.some((u) => {
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (h !== d && !h.endsWith(`.${d}`)) return false;
|
||||
if (c.secure && u.protocol !== "https:") return false;
|
||||
return u.pathname.startsWith(c.path || "/");
|
||||
});
|
||||
}
|
||||
|
||||
export async function register(app, ctx) {
|
||||
const { log } = ctx;
|
||||
const profileDir = process.env.CAMOFOX_PROFILE_DIR || ctx.config.profileDir;
|
||||
|
||||
app.post("/umt/sessions/:userId/cookies", ctx.auth(), express.json({ limit: "512kb" }), async (req, res) => {
|
||||
try {
|
||||
const userId = ctx.normalizeUserId(req.params.userId);
|
||||
const cookies = req.body?.cookies;
|
||||
if (!Array.isArray(cookies) || cookies.length > 500) {
|
||||
return res.status(400).json({ error: "cookies must be an array of at most 500" });
|
||||
}
|
||||
const clean = [];
|
||||
for (const c of cookies) {
|
||||
if (!c || typeof c !== "object" || typeof c.name !== "string" || !c.name) continue;
|
||||
if (typeof c.value !== "string" || typeof c.domain !== "string" || !c.domain) continue;
|
||||
const o = {};
|
||||
for (const k of FIELDS) if (c[k] !== undefined) o[k] = c[k];
|
||||
if (!o.path) o.path = "/";
|
||||
clean.push(o);
|
||||
}
|
||||
if (clean.length !== cookies.length) return res.status(400).json({ error: "invalid cookie objects" });
|
||||
const session = await ctx.getSession(userId);
|
||||
await session.context.addCookies(clean);
|
||||
log("info", "umt cookies imported", { reqId: req.reqId, userId, count: clean.length });
|
||||
// 持久化插件监听此事件立即落盘(与上游导入端点相同)
|
||||
ctx.events.emit("session:cookies:import", { userId, count: clean.length });
|
||||
res.json({ ok: true, userId, count: clean.length });
|
||||
} catch (err) {
|
||||
log("error", "umt cookie import failed", { reqId: req.reqId, error: err.message });
|
||||
res.status(500).json({ error: ctx.safeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/umt/sessions/:userId/cookies", ctx.auth(), async (req, res) => {
|
||||
try {
|
||||
const userId = ctx.normalizeUserId(req.params.userId);
|
||||
const urls = parseUrls(req.query.urls);
|
||||
if (!urls.length) return res.status(400).json({ error: "urls required" });
|
||||
const live = ctx.sessions.get(userId);
|
||||
if (live && !live._closing) {
|
||||
try {
|
||||
const cookies = await live.context.cookies(urls.map((u) => u.href));
|
||||
return res.json({ source: "live", cookies });
|
||||
} catch {
|
||||
// 上下文正被关闭:退回读落盘文件
|
||||
}
|
||||
}
|
||||
if (profileDir) {
|
||||
const { storageStatePath } = getUserPersistencePaths(profileDir, userId);
|
||||
try {
|
||||
const state = JSON.parse(await fs.readFile(storageStatePath, "utf8"));
|
||||
const nowSec = Date.now() / 1000;
|
||||
const cookies = (Array.isArray(state.cookies) ? state.cookies : []).filter((c) => matches(c, urls, nowSec));
|
||||
return res.json({ source: "persisted", cookies });
|
||||
} catch {}
|
||||
}
|
||||
res.json({ source: "none", cookies: [] });
|
||||
} catch (err) {
|
||||
log("error", "umt cookie read failed", { reqId: req.reqId, error: err.message });
|
||||
res.status(500).json({ error: ctx.safeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
log("info", "umt-cookies plugin: registered GET/POST /umt/sessions/:userId/cookies");
|
||||
}
|
||||
@@ -340,3 +340,65 @@ docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./umt-root.cr
|
||||
- 一个桌面承载所有站点会话:持有 A 站租约的人在画面里能看到 / 操作切到前台的其它窗口;目前同一时刻一般只有一个会话窗口。
|
||||
- 真实输入法(Windows 微软拼音、macOS 日文)、Firefox / Safari 客户端、真实 PT 站点未测;容器内 fcitx5 只验证能启动,未验证组字。
|
||||
- 画面坐标与 camofox 截图坐标不同(桌面含 56 px 工具栏,Camoufox 伪装了 `screenX` / `outerHeight`),Agent 视觉操作继续用 camofox 截图。
|
||||
|
||||
## 9. 凭据统一:站点账号登录态 ⇄ 浏览器(BR-2)
|
||||
|
||||
一处管理凭据:站点账号里的 Cookie(手填,或账密自动登录得到的会话 Cookie)在打开浏览器会话时注入该站点 / 账号的 profile;
|
||||
人在浏览器里登录后,登录态读回站点账号,HTTP 抓取、RSS、快照下一次就用新的。截图模式与桌面模式是同一个 camofox 进程,共用。
|
||||
代码:`sources-identity/service/credentials.ts`(单一读写入口)、`modules/browser/service.ts`(`prepareCookies` / `syncCookies`)、
|
||||
`packages/adapters/src/browser/{cookies.ts,camofox/client.ts}`、camofox 插件 `docker/browser-desktop/root/opt/camofox/plugins/umt-cookies`。
|
||||
|
||||
### 9.1 注入做法
|
||||
|
||||
camofox 1.17.0 只有 `POST /sessions/:userId/cookies`(导入),而且要另一个口令 `CAMOFOX_API_KEY`;没有读取端点。
|
||||
所以桌面镜像带一个最小插件 **umt-cookies**(Dockerfile 把它加进 `camofox.config.json` 的插件清单),在访问凭据
|
||||
(`CAMOFOX_ACCESS_KEY`)之下提供:
|
||||
|
||||
| 端点 | 说明 |
|
||||
| --- | --- |
|
||||
| `POST /umt/sessions/{userId}/cookies` `{cookies}` | `context.addCookies`,并触发持久化插件立即落盘(与上游导入端点相同) |
|
||||
| `GET /umt/sessions/{userId}/cookies?urls=a,b` | 作用于这些地址的 Cookie:会话活着读 `context.cookies(urls)`,否则读 profile 落盘文件 |
|
||||
|
||||
适配器先走插件;插件不在(404)再试上游导入端点(`CAMOFOX_API_KEY` 与访问凭据相同时可用,只能写不能读);都不行 → 会话照常打开,
|
||||
`credential.status = unsupported` 并说明。profile 落盘在 `/root/.camofox/profiles/<sha256(userId) 前 32 位>/storage-state.json`
|
||||
(Playwright storageState:cookies + localStorage;sessionStorage 不保存,IndexedDB 缺省不保存),桌面镜像里目录 0700、文件 0600
|
||||
(`svc-camofox` 设 `umask 077`,`init-umt-desktop` 对旧卷 `chmod -R go-rwx`)。**改了镜像,需重建 `umt-browser-desktop` 后生效**
|
||||
(`docker compose build camofox && docker compose up -d camofox`);纯截图镜像 `camofox-browser:1.17.0` 不带插件,注入 / 回写都显示不支持。
|
||||
|
||||
打开会话(`POST /browser/sessions`、Agent `browser.navigate` 首次打开)时:
|
||||
|
||||
1. 匿名会话、API Key 类站点 / 账号(馒头:网页登录态与 API Key 无关)→ 不注入,`not_applicable`;
|
||||
2. 取账号 Cookie:用户填的 + 自动登录得到的会话 Cookie,同名以会话 Cookie 为准;账密账号还没有会话 Cookie → 先走一次 refresh-login;
|
||||
3. Cookie 头串转成站点主机的 host-only Cookie(路径 `/`,https 站点标 secure,期限 30 天——头串里没有期限,站点响应会按自己的期限覆盖),
|
||||
**在开第一个标签页之前**写进 profile,再导航;
|
||||
4. 注入了仍落在登录页 → `credential.status = expired`;Cookie 账号(没有密码可自动登录)转等待处理 `login_required`
|
||||
("登录已失效:接管后在画面里登录一次,交还后全站生效"),账密账号留给 Agent 用凭证注入登录。
|
||||
|
||||
会话详情 `credential`:`status`、`source`(`manual` 手填 / `login` 自动登录 / `browser` 浏览器回写)、`sourceAt`、`count`(个数,不含值)、
|
||||
`message`、`syncedAt`;原站视图顶栏显示"登录态:来源 · 时间"。
|
||||
|
||||
### 9.2 回写
|
||||
|
||||
| 时机 | 做法 |
|
||||
| --- | --- |
|
||||
| 人工接管交还 | 先按上游 `GET /tabs` 同步当前地址(桌面里的导航不经 API),不在登录页才读回 |
|
||||
| 关闭会话 | 关闭前读回一次(站点 / 浏览器服务停用导致的关闭不读) |
|
||||
| 定时 | 会话有动作时,距上次读回(或创建)满 10 分钟,后台读回一次 |
|
||||
| refresh-login 发现 Cookie 失效 | Cookie 账号:从浏览器 profile 读回(不要求会话开着,读落盘文件),与存的不同就拿它验证一次,有效才回写并标 active |
|
||||
|
||||
读回只取作用于站点主机的、未过期的 Cookie(统计 / CDN / 第三方域名一律丢弃),与存储比较(忽略顺序),有变化才写:
|
||||
Cookie 账号替换 `secret_ref`,账密账号替换(或新建)会话 Cookie;`site_account.user_info.cookieOrigin = {source: "browser", by: "browser", at}`,
|
||||
`expired` / `invalid` 的账号回到 `unknown`。来源判定:`cookieOrigin.at` 不早于 secret 最近写入(`rotated_at`,留 5 秒)才算浏览器回写,
|
||||
之后再手填或自动登录会让它变回 `manual` / `login`。
|
||||
|
||||
安全:Cookie 值只在服务端、secret 表与浏览器服务之间传;日志只记个数;会话详情 / 能力输出 / 模型上下文里没有值;快照遮蔽照旧(§3)。
|
||||
|
||||
### 9.3 真机核验(2026-09-29)
|
||||
|
||||
`scripts/browser-verify/cookie-real.ts`:临时容器 `camofox-browser:1.17.0` + 挂载 umt-cookies 插件(与用户栈同一网络、同一出口代理),
|
||||
读用户栈数据库里憨憨(hhanclub)账号的手填 Cookie(5 个),注入**全新** profile 后打开首页一次:HTTP 200、落在 `/` 不是登录页、
|
||||
页面有 `a[href*=logout.php]`(站点定义的登录成功标记)→ 已登录;插件读回 5 个、全部是 `hhanclub.net` 域名、包含注入的全部名字。
|
||||
对真实站点只有这一次导航,未截图。
|
||||
|
||||
没有核验的:桌面镜像重建后的端到端(接管 → 画面里登录 → 交还回写)只在 mock 上测过;其它三站;sessionStorage / IndexedDB 登录的站点
|
||||
(Cookie 注入不覆盖这类登录态)。站点账号列表里显示登录态来源待前端接 `userInfo.cookieOrigin`。
|
||||
@@ -18,6 +18,7 @@
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserHealth,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
@@ -150,6 +151,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
private readonly sink: DownloadSink;
|
||||
private readonly now: () => Date;
|
||||
private readonly newId: () => string;
|
||||
/** Cookie 接口:plugin = umt-cookies 插件(读写);upstream = 上游导入端点(只写,要求 CAMOFOX_API_KEY 与访问凭据相同);none = 都没有 */
|
||||
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
||||
|
||||
constructor(private readonly cfg: CamofoxConfig) {
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
@@ -368,6 +371,16 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
|
||||
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
|
||||
const sessionId = this.newId();
|
||||
let cookies: OpenSessionResult["cookies"];
|
||||
if (req.cookies?.length) {
|
||||
try {
|
||||
cookies = { status: "injected", count: (await this.putCookies(profileKey, req.cookies)).count };
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 };
|
||||
else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 };
|
||||
else throw e;
|
||||
}
|
||||
}
|
||||
const res = await this.request("POST", "/tabs", {
|
||||
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
|
||||
timeoutMs: req.budget.timeoutMs,
|
||||
@@ -425,7 +438,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
};
|
||||
this.assertLanded(rawUrl, req.budget);
|
||||
}
|
||||
return { session: { ...session }, tab: { ...tab }, navigation };
|
||||
return { session: { ...session }, tab: { ...tab }, navigation, ...(cookies ? { cookies } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +455,91 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
||||
}
|
||||
|
||||
// ---------- Cookie(BR-2 凭据统一) ----------
|
||||
|
||||
/**
|
||||
* 写 Cookie:先走 umt-cookies 插件 `POST /umt/sessions/{userId}/cookies`(docker/browser-desktop 自带,访问凭据即可);
|
||||
* 插件不在(404)再试上游 `POST /sessions/{userId}/cookies`(1.17.0 要求 CAMOFOX_API_KEY,与访问凭据相同时可用);
|
||||
* 都不行报 not_implemented。两条路上游都会立即把 profile 落盘。
|
||||
*/
|
||||
setCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
||||
return this.withProfile(profileKey, () => this.putCookies(profileKey, cookies));
|
||||
}
|
||||
|
||||
private async putCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
||||
const list = cookies.slice(0, 500);
|
||||
const body = { cookies: list } as unknown as Json;
|
||||
const path = `/sessions/${encodeURIComponent(profileKey)}/cookies`;
|
||||
if (this.cookieApi === null || this.cookieApi === "plugin") {
|
||||
try {
|
||||
const res = await this.json(
|
||||
await this.request("POST", `/umt${path}`, { body }),
|
||||
"/umt/sessions/{userId}/cookies",
|
||||
);
|
||||
this.cookieApi = "plugin";
|
||||
return { count: int(res.count) ?? list.length };
|
||||
} catch (e) {
|
||||
if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e;
|
||||
this.cookieApi = null;
|
||||
}
|
||||
}
|
||||
if (this.cookieApi === null || this.cookieApi === "upstream") {
|
||||
try {
|
||||
const res = await this.json(await this.request("POST", path, { body }), "/sessions/{userId}/cookies");
|
||||
this.cookieApi = "upstream";
|
||||
return { count: int(res.count) ?? list.length };
|
||||
} catch (e) {
|
||||
// 403:上游导入口令(CAMOFOX_API_KEY)未设置或与访问凭据不同
|
||||
if (!(e instanceof BrowserError && (e.httpStatus === 403 || e.httpStatus === 404))) throw e;
|
||||
this.cookieApi = "none";
|
||||
}
|
||||
}
|
||||
throw new BrowserError("not_implemented", "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件)");
|
||||
}
|
||||
|
||||
/** 读 Cookie:只有 umt-cookies 插件提供(上游 1.17.0 没有读取端点);返回作用于这些地址的 Cookie */
|
||||
async getCookies(profileKey: string, urls: string[]): Promise<BrowserCookie[]> {
|
||||
if (this.cookieApi === "upstream" || this.cookieApi === "none")
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
||||
let res: Json;
|
||||
try {
|
||||
res = await this.json(
|
||||
await this.request("GET", `/umt/sessions/${encodeURIComponent(profileKey)}/cookies`, {
|
||||
query: { urls: urls.join(",") },
|
||||
}),
|
||||
"/umt/sessions/{userId}/cookies",
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.httpStatus === 404) {
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
this.cookieApi = "plugin";
|
||||
const list = Array.isArray(res.cookies) ? res.cookies : [];
|
||||
const out: BrowserCookie[] = [];
|
||||
for (const c of list) {
|
||||
if (!c || typeof c !== "object") continue;
|
||||
const o = c as Json;
|
||||
const name = str(o.name);
|
||||
const domain = str(o.domain);
|
||||
const value = str(o.value);
|
||||
if (!name || !domain || value === undefined) continue;
|
||||
const sameSite = str(o.sameSite);
|
||||
out.push({
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path: str(o.path) ?? "/",
|
||||
expires: typeof o.expires === "number" ? o.expires : -1,
|
||||
httpOnly: o.httpOnly === true,
|
||||
secure: o.secure === true,
|
||||
...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None" ? { sameSite } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
|
||||
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
|
||||
const s = this.state(sessionId);
|
||||
@@ -567,6 +665,18 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
if (lost.length) s.refsValid = false;
|
||||
if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? "";
|
||||
// 桌面模式下人在画面里导航不经本适配器:按上游报告的当前地址同步(登录页判定、回写登录态前要用最新地址)
|
||||
const cur = list.find((t) => (str(t.tabId) ?? str(t.targetId)) === s.currentTabId);
|
||||
const curUrl = cur ? str(cur.url) : undefined;
|
||||
if (curUrl && curUrl !== s.rawUrl && /^https?:/.test(curUrl)) {
|
||||
s.refsValid = false;
|
||||
this.touch(s, curUrl);
|
||||
const tab = s.tabs.get(s.currentTabId);
|
||||
if (tab) {
|
||||
tab.url = s.session.currentUrl;
|
||||
tab.updatedAt = at;
|
||||
}
|
||||
}
|
||||
return { alive, lost };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createMockCamofox, type MockCamofox } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
cookieDomainMatches,
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
headerFromCookies,
|
||||
mergeCookieHeaders,
|
||||
parseCookieHeader,
|
||||
profileKeyFor,
|
||||
sameCookieHeader,
|
||||
staticBrowserSecrets,
|
||||
} from "./index.ts";
|
||||
|
||||
// BR-2:Cookie 头串 ⇄ 浏览器 Cookie 互转,与 camofox Cookie 接口(umt-cookies 插件 / 上游导入端点 / 都没有)。
|
||||
|
||||
const ACCESS_KEY = "camofox-access-key-br2";
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
|
||||
const SECRET = "c0ffee-session-value-br2";
|
||||
|
||||
describe("Cookie 头串互转", () => {
|
||||
it("解析 / 合并(后者优先)/ 等价比较", () => {
|
||||
expect([...parseCookieHeader(" a=1; b = 2 ;;bad; c=x=y ")]).toEqual([
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["c", "x=y"],
|
||||
]);
|
||||
expect(mergeCookieHeaders("a=1; b=2", "b=3; c=4")).toBe("a=1; b=3; c=4");
|
||||
expect(mergeCookieHeaders(null, "")).toBeNull();
|
||||
expect(sameCookieHeader("a=1; b=2", "b=2;a=1")).toBe(true);
|
||||
expect(sameCookieHeader("a=1", "a=2")).toBe(false);
|
||||
});
|
||||
|
||||
it("注入对象:站点主机 host-only、https → secure、30 天期限", () => {
|
||||
const list = cookiesFromHeader("uid=1; pass=x", "https://hhanclub.net/", 1000);
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "uid",
|
||||
domain: "hhanclub.net",
|
||||
path: "/",
|
||||
secure: true,
|
||||
expires: 1000 + 30 * 86400,
|
||||
}),
|
||||
expect.objectContaining({ name: "pass", value: "x" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("读回只取站点域名、未过期的 Cookie;同名取更具体的", () => {
|
||||
expect(cookieDomainMatches(".hhanclub.net", "www.hhanclub.net")).toBe(true);
|
||||
expect(cookieDomainMatches("hhanclub.net", "evilhhanclub.net")).toBe(false);
|
||||
const h = headerFromCookies(
|
||||
[
|
||||
{ name: "pass", value: "root", domain: ".hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "pass", value: "host", domain: "hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "old", value: "1", domain: "hhanclub.net", path: "/", expires: 10, httpOnly: false, secure: false },
|
||||
{ name: "_ga", value: "t", domain: ".google.com", path: "/", expires: -1, httpOnly: false, secure: false },
|
||||
],
|
||||
"https://hhanclub.net/",
|
||||
100,
|
||||
);
|
||||
expect(h).toBe("pass=host");
|
||||
expect(headerFromCookies([], "https://hhanclub.net/")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("camofox Cookie 接口", () => {
|
||||
let mock: MockCamofox | undefined;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = undefined;
|
||||
});
|
||||
const provider = (m: MockCamofox, logs?: Parameters<BrowserLogger>[0][]) =>
|
||||
createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: m.url,
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: ACCESS_KEY }),
|
||||
...(logs ? { logger: (e) => void logs.push(e) } : {}),
|
||||
});
|
||||
const key = profileKeyFor(SITE, ACCOUNT);
|
||||
const cookies = cookiesFromHeader(`c_secure_uid=1001; c_secure_pass=${SECRET}`, "https://audiences.example/");
|
||||
|
||||
it("umt-cookies 插件:写入(带访问凭据)与读回;日志不含 Cookie 值", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const logs: Parameters<BrowserLogger>[0][] = [];
|
||||
const p = provider(mock, logs);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
const call = mock.callsTo("/umt/sessions/")[0]!;
|
||||
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
const back = await p.getCookies(key, ["https://audiences.example/"]);
|
||||
expect(headerFromCookies(back, "https://audiences.example/")).toBe(`c_secure_pass=${SECRET}; c_secure_uid=1001`);
|
||||
expect(await p.getCookies(key, ["https://other.example/"])).toEqual([]);
|
||||
expect(JSON.stringify(logs)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("openSession 带 cookies:先注入再开标签页", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
initialUrl: "https://audiences.example/",
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "injected", count: 2 });
|
||||
const paths = mock.calls.map((c) => c.path);
|
||||
expect(paths.findIndex((x) => x.startsWith("/umt/"))).toBeLessThan(paths.indexOf("/tabs"));
|
||||
});
|
||||
|
||||
it("没有插件:回退上游导入端点(CAMOFOX_API_KEY 与访问凭据相同时);读回报不支持", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream", cookieImportKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
expect(mock.cookieJars.get(key)).toHaveLength(2);
|
||||
await expect(p.getCookies(key, ["https://audiences.example/"])).rejects.toMatchObject({ kind: "not_implemented" });
|
||||
});
|
||||
|
||||
it("插件与上游导入都不可用:not_implemented;openSession 照常打开并标 unsupported", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream" });
|
||||
const p = provider(mock);
|
||||
const e = await p.setCookies(key, cookies).catch((x: unknown) => x);
|
||||
expect(e).toBeInstanceOf(BrowserError);
|
||||
expect((e as BrowserError).kind).toBe("not_implemented");
|
||||
expect((e as BrowserError).message).not.toContain(SECRET);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "unsupported", count: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { BrowserCookie } from "@umt/contracts";
|
||||
|
||||
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
|
||||
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
|
||||
|
||||
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
|
||||
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
|
||||
|
||||
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
|
||||
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const part of (header ?? "").split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i <= 0) continue;
|
||||
const name = part.slice(0, i).trim();
|
||||
if (!name || /[\s,;]/.test(name)) continue;
|
||||
out.set(name, part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
|
||||
export function formatCookieHeader(jar: Map<string, string>): string {
|
||||
return [...jar.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
|
||||
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
|
||||
const jar = new Map<string, string>();
|
||||
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
|
||||
return jar.size ? formatCookieHeader(jar) : null;
|
||||
}
|
||||
|
||||
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
|
||||
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const x = parseCookieHeader(a);
|
||||
const y = parseCookieHeader(b);
|
||||
if (x.size !== y.size) return false;
|
||||
for (const [k, v] of x) if (y.get(k) !== v) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
|
||||
export function cookiesFromHeader(
|
||||
header: string,
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): BrowserCookie[] {
|
||||
const u = new URL(siteUrl);
|
||||
const secure = u.protocol === "https:";
|
||||
return [...parseCookieHeader(header)].map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
domain: u.hostname,
|
||||
path: "/",
|
||||
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "Lax" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
|
||||
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
|
||||
const d = cookieDomain.toLowerCase().replace(/^\./, "");
|
||||
const h = host.toLowerCase();
|
||||
return h === d || h.endsWith(`.${d}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
|
||||
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
|
||||
*/
|
||||
export function headerFromCookies(
|
||||
cookies: readonly BrowserCookie[],
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): string | null {
|
||||
const u = new URL(siteUrl);
|
||||
const jar = new Map<string, { v: string; specific: number }>();
|
||||
for (const c of cookies) {
|
||||
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
|
||||
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
|
||||
const path = c.path || "/";
|
||||
if (!u.pathname.startsWith(path)) continue;
|
||||
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
|
||||
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
|
||||
const prev = jar.get(c.name);
|
||||
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
|
||||
}
|
||||
if (!jar.size) return null;
|
||||
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./budget.ts";
|
||||
export * from "./camofox/index.ts";
|
||||
export * from "./cookies.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./image.ts";
|
||||
export * from "./none.ts";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserHealth,
|
||||
BrowserLeaseHolderKind,
|
||||
BrowserProviderKind,
|
||||
@@ -65,6 +66,18 @@ export interface BrowserProvider {
|
||||
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
|
||||
*/
|
||||
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
|
||||
|
||||
// ── BR-2 凭据统一 ──
|
||||
/**
|
||||
* 把 Cookie 写进该 profile 的浏览器上下文(并落盘到 profile)。上游没有可用的 Cookie 接口时报
|
||||
* BrowserError("not_implemented")。Cookie 值不进日志与错误信息。
|
||||
*/
|
||||
setCookies?(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }>;
|
||||
/**
|
||||
* 读该 profile 作用于这些地址的 Cookie(活动上下文优先,否则读 profile 落盘文件);没有时返回空数组。
|
||||
* 上游没有接口时报 BrowserError("not_implemented")。
|
||||
*/
|
||||
getCookies?(profileKey: string, urls: string[]): Promise<BrowserCookie[]>;
|
||||
}
|
||||
|
||||
export interface PageMetrics {
|
||||
@@ -106,12 +119,16 @@ export interface OpenSessionRequest {
|
||||
/** 可选初始地址,同样受 budget 约束 */
|
||||
initialUrl?: string;
|
||||
budget: NavigationBudget;
|
||||
/** 打开前先注入的 Cookie(站点账号登录态,BR-2);注入失败不阻止打开,结果见 OpenSessionResult.cookies */
|
||||
cookies?: BrowserCookie[];
|
||||
}
|
||||
|
||||
export interface OpenSessionResult {
|
||||
session: BrowserSession;
|
||||
tab: BrowserTab;
|
||||
navigation: NavigationResult | null;
|
||||
/** 传了 cookies 时:injected = 注入个数;unsupported = 上游没有 Cookie 接口;failed = 请求失败 */
|
||||
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
|
||||
}
|
||||
|
||||
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */
|
||||
|
||||
@@ -88,33 +88,14 @@ PT 站点适配器,按 MoviePilot v3(提交 e6820a8)迁移:通用配置驱动 sp
|
||||
|
||||
| 状态 | 站点 |
|
||||
| --- | --- |
|
||||
| 启用,**已实测 2026-09-29**(真实账号只读核对) | hhanclub 憨憨 |
|
||||
| 启用(主力,仍须真实账号核验) | audiences 观众、chdbits 彩虹岛、mteam 馒头 |
|
||||
| 启用(主力,仍须真实账号核验) | audiences 观众、hhanclub 憨憨、chdbits 彩虹岛、mteam 馒头 |
|
||||
| `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg |
|
||||
|
||||
未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。
|
||||
|
||||
### 待真实站点核验(W1-3c 新增)
|
||||
|
||||
1. HHClub(2026-09-29 已实测,见下节):仍未见到的只有促销(`promotion-tag-*`)与 H&R(`div.torrent-info-hr`)命中的实例——当次第 1 页 100 条全部无促销、无 H&R,类名与 Jackett 现行定义一致,暂不改。
|
||||
|
||||
### HHClub 实测记录(2026-09-29)
|
||||
|
||||
真实账号只读核对,对站点共发 1 次详情请求(列表、用户数据、健康检测用添加站点时产品自己抓的结果;下载令牌只核对列表里的下载链接形态,没有下载)。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 列表 `div.torrent-table-sub-info` 卡片、分类 `?cat[]=` 链接、大小 / 做种 / 下载 / 完成 | 正确,全为数字;分类 401–405/412 原始 ID 与名称都在 |
|
||||
| 标题 | **有误,已修**:标题链接文本末尾带新种标记「[新]」(100 条里 58 条;详情页标题没有),`title` 加 `replace` 过滤 |
|
||||
| 副标题 | 正确,纯文本,无 HTML 残片 |
|
||||
| 发布时间 | 正确:`span[title]` 绝对时间按 `+08:00` 转 UTC,相对时间只进 `date_elapsed` |
|
||||
| 详情页 | 标题 / 副标题取到;没有做种、完成、分类名、基本信息行,**刷新详情会用 null 冲掉列表值,已修**(服务端合并,见 `mergeDetailOverList`) |
|
||||
| 用户数据(`NexusHhanclub`) | 正确;等级「贵宾」之前 `vip` 为 null,**已加 `umt.vipLevels`** |
|
||||
| 健康检测 / 登录标志 `a[href='logout.php']` | 正确 |
|
||||
| 下载链接 | 列表给 `download.php?id=…&passkey=…`,入库时 passkey 脱敏 |
|
||||
| 标签(官方 / 中字 / 国语等) | 列表没有可用的标签选择器;界面按标题解析与副标题原文生成规格标签 |
|
||||
|
||||
访问默认(`umt.defaults`,与 `settings.sites` 取更保守的一方):快照每 60 分钟只抓第 1 页、健康检测 6 小时、用户数据 6 小时、实时搜索并发 1、详情缓存 24 小时;限流 3 秒 1 个令牌、突发 2。fixture:`@umt/testkit` 的 `HHANCLUB_LIST_PAGE_2026`(脱敏最小片段)。
|
||||
1. HHClub:`div.torrent-table-sub-info` 卡片结构、`?cat[]=` 分类链接(若 URL 编码为 `cat%5B%5D=` 则 `\d+` 会取错,需改 querystring 过滤)、`promotion-tag-*` 新旧类名、`div.torrent-info-hr`、详情页 Tailwind 选择器、分页 0 起。
|
||||
2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。
|
||||
3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。
|
||||
4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
CHDBITS_LIST_PAGE,
|
||||
CHDBITS_LOGIN_PAGE,
|
||||
HHANCLUB_DETAIL_PAGE,
|
||||
HHANCLUB_LIST_PAGE,
|
||||
HHANCLUB_LIST_PAGE_2026,
|
||||
} from "@umt/testkit";
|
||||
import { CHDBITS_LIST_PAGE, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSiteDefinition, listSiteDefinitions } from "../registry.ts";
|
||||
import { parseDetailPage } from "../spider/detail.ts";
|
||||
@@ -108,44 +102,6 @@ describe("HHClub 定义(div 卡片模板)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("2026-09-29 实测结构:去掉新种标记「[新]」,短剧 412、完成数 0、无促销、时间按 +08:00、下载链接 passkey 脱敏", () => {
|
||||
const [a, b] = parse(HHANCLUB_LIST_PAGE_2026);
|
||||
expect(a).toMatchObject({
|
||||
siteReleaseId: "91001",
|
||||
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
|
||||
subtitle: "示例短剧 | 全30集 | 1080p",
|
||||
category: { id: "412", name: "短剧" },
|
||||
sizeBytes: Math.round(185.38 * 1024 ** 2),
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
snatched: 0,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
hr: false,
|
||||
uploadedAt: "2026-09-29T06:07:03.000Z",
|
||||
detailUrl: "https://hhanclub.net/details.php?id=91001&hit=1",
|
||||
});
|
||||
expect(a!.downloadUrl).not.toContain("0000000000000000");
|
||||
expect(b).toMatchObject({
|
||||
title: "Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
|
||||
category: { id: "402", name: "电视剧" },
|
||||
snatched: 187,
|
||||
uploadedAt: "2026-09-29T04:03:28.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("站点访问默认:快照只抓 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1;3 秒 1 个令牌、突发 2", () => {
|
||||
expect(hhanclubDefinition.umt.defaults).toEqual({
|
||||
snapshotIntervalSec: 3600,
|
||||
snapshotMaxPages: 1,
|
||||
healthCheckIntervalSec: 21600,
|
||||
userDataIntervalSec: 21600,
|
||||
searchConcurrency: 1,
|
||||
detailCacheSec: 86400,
|
||||
});
|
||||
expect(hhanclubDefinition.umt.rateLimit).toEqual({ minIntervalMs: 3000, burst: 2 });
|
||||
expect(hhanclubDefinition.umt.vipLevels).toContain("贵宾");
|
||||
});
|
||||
|
||||
it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => {
|
||||
expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe(
|
||||
"https://hhanclub.net/torrents.php?page=0",
|
||||
|
||||
@@ -86,17 +86,7 @@
|
||||
"attribute": "title"
|
||||
},
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
|
||||
"filters": [
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["[新]", ""]
|
||||
},
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["(新)", ""]
|
||||
}
|
||||
]
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
|
||||
},
|
||||
"details": {
|
||||
"selector": "div.torrent-title a[href*='details.php']",
|
||||
@@ -185,13 +175,13 @@
|
||||
}
|
||||
},
|
||||
"umt": {
|
||||
"version": "[email protected]+umt.1",
|
||||
"version": "[email protected]",
|
||||
"kind": "nexusphp",
|
||||
"displayName": "憨憨",
|
||||
"timezone": "+08:00",
|
||||
"rateLimit": {
|
||||
"minIntervalMs": 3000,
|
||||
"burst": 2
|
||||
"burst": 1
|
||||
},
|
||||
"capabilities": {
|
||||
"list": true,
|
||||
@@ -259,15 +249,6 @@
|
||||
"douban": "a[href*='douban.com']::attr(href)",
|
||||
"fileList": "td.rowfollow > ul > li::text"
|
||||
}
|
||||
},
|
||||
"vipLevels": ["贵宾", "VIP"],
|
||||
"defaults": {
|
||||
"snapshotIntervalSec": 3600,
|
||||
"snapshotMaxPages": 1,
|
||||
"healthCheckIntervalSec": 21600,
|
||||
"userDataIntervalSec": 21600,
|
||||
"searchConcurrency": 1,
|
||||
"detailCacheSec": 86400
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,6 @@ const CapabilitiesSchema = z.strictObject({
|
||||
download: z.boolean(),
|
||||
rss: z.boolean(),
|
||||
});
|
||||
const AccessDefaultsSchema = z.strictObject({
|
||||
snapshotIntervalSec: z.number().int().min(300).optional(),
|
||||
snapshotMaxPages: z.number().int().min(1).max(10).optional(),
|
||||
healthCheckIntervalSec: z.number().int().min(300).optional(),
|
||||
userDataIntervalSec: z.number().int().min(300).optional(),
|
||||
searchConcurrency: z.number().int().min(1).max(4).optional(),
|
||||
detailCacheSec: z.number().int().min(0).optional(),
|
||||
});
|
||||
const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) });
|
||||
const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移");
|
||||
const PairSchema = z.tuple([z.string(), z.string()]);
|
||||
@@ -95,8 +87,6 @@ const UmtNexusSchema = z.strictObject({
|
||||
authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(),
|
||||
timezone: TimezoneSchema,
|
||||
rateLimit: RateLimitSchema,
|
||||
/** 站点级访问默认(与 settings.sites 取更保守的一方) */
|
||||
defaults: AccessDefaultsSchema.optional(),
|
||||
capabilities: CapabilitiesSchema,
|
||||
categoryNames: z.record(z.string(), z.string()).optional(),
|
||||
loginSuccess: z.string().optional(),
|
||||
|
||||
@@ -121,21 +121,6 @@ export interface IndexerDefinition {
|
||||
umt: UmtSiteExtension;
|
||||
}
|
||||
|
||||
export interface SiteAccessDefaults {
|
||||
/** 定时快照周期(秒) */
|
||||
snapshotIntervalSec?: number;
|
||||
/** 快照(定时 / 手动)最多抓几页列表 */
|
||||
snapshotMaxPages?: number;
|
||||
/** 健康检测最短间隔(秒) */
|
||||
healthCheckIntervalSec?: number;
|
||||
/** 用户数据刷新间隔(秒;定时任务 sites.user_data 的周期,仅作说明与校验) */
|
||||
userDataIntervalSec?: number;
|
||||
/** 同站实时搜索并发 */
|
||||
searchConcurrency?: number;
|
||||
/** 详情页结果缓存(秒):期内不重复抓详情 */
|
||||
detailCacheSec?: number;
|
||||
}
|
||||
|
||||
export interface UmtSiteExtension {
|
||||
/** 定义版本,入 site_snapshot */
|
||||
version: string;
|
||||
@@ -166,11 +151,6 @@ export interface UmtSiteExtension {
|
||||
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
|
||||
timezone: string;
|
||||
rateLimit: { minIntervalMs: number; burst: number };
|
||||
/**
|
||||
* 站点级访问默认(本项目扩展):按站点实测给出的保守下限。运行时与 settings.sites 取更保守的一方
|
||||
* (周期 / 缓存取较长,页数 / 并发取较小);站点行自己设了快照周期时以站点行为准。
|
||||
*/
|
||||
defaults?: SiteAccessDefaults;
|
||||
capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean };
|
||||
/** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */
|
||||
categoryNames?: Record<string, string>;
|
||||
|
||||
@@ -286,6 +286,47 @@ export const BrowserProvidersQuery = z.object({
|
||||
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
|
||||
|
||||
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
|
||||
// ── BR-2 凭据统一:站点账号 Cookie ⇄ 浏览器 profile ──
|
||||
|
||||
/** 浏览器 Cookie(Playwright 形状);value 是凭据,只在服务端与浏览器服务之间传,不进日志、模型与 API 响应 */
|
||||
export const BrowserCookie = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string(),
|
||||
/** 主机名(host-only)或 .根域 */
|
||||
domain: z.string().min(1),
|
||||
path: z.string().default("/"),
|
||||
/** Unix 秒;-1 = 会话 Cookie */
|
||||
expires: z.number().default(-1),
|
||||
httpOnly: z.boolean().default(false),
|
||||
secure: z.boolean().default(false),
|
||||
sameSite: z.enum(["Strict", "Lax", "None"]).optional(),
|
||||
});
|
||||
export type BrowserCookie = z.infer<typeof BrowserCookie>;
|
||||
|
||||
/** 站点账号登录态(Cookie)来源:手填 / 账密自动登录 / 浏览器回写 */
|
||||
export const SiteCookieSource = z.enum(["manual", "login", "browser"]);
|
||||
export type SiteCookieSource = z.infer<typeof SiteCookieSource>;
|
||||
|
||||
/**
|
||||
* 会话打开时的凭据注入结果(存 recovery_state.credential):
|
||||
* injected = 已把账号 Cookie 注入浏览器 profile;none = 账号没有可注入的 Cookie;
|
||||
* not_applicable = 匿名会话或 API Key 类站点(网页登录态与 API Key 无关,不注入);
|
||||
* unsupported = 浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件且上游导入口令不同);
|
||||
* failed = 注入请求失败;expired = 注入后首页仍是登录页(Cookie 过期),会话转"需要人工登录"。
|
||||
*/
|
||||
export const BrowserCredentialState = z.object({
|
||||
status: z.enum(["injected", "none", "not_applicable", "unsupported", "failed", "expired"]),
|
||||
source: SiteCookieSource.nullable(),
|
||||
/** 该来源最近一次写入凭据的时间 */
|
||||
sourceAt: Timestamp.nullable(),
|
||||
/** 注入的 Cookie 个数(不含值) */
|
||||
count: NonNegInt,
|
||||
message: z.string().nullable(),
|
||||
/** 最近一次从浏览器读回并比对的时间 */
|
||||
syncedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type BrowserCredentialState = z.infer<typeof BrowserCredentialState>;
|
||||
|
||||
export const BrowserSessionView = BrowserSession.extend({
|
||||
tabs: z.array(BrowserTab),
|
||||
currentTabId: z.string().nullable(),
|
||||
@@ -294,6 +335,8 @@ export const BrowserSessionView = BrowserSession.extend({
|
||||
needsSnapshot: z.boolean(),
|
||||
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
|
||||
waitingReason: z.string().nullable(),
|
||||
/** 打开会话时的凭据注入与回写状态(BR-2);旧会话没有 */
|
||||
credential: BrowserCredentialState.nullable().optional(),
|
||||
});
|
||||
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 站点请求计数:每次从站点限流器拿到令牌(列表 / 搜索 / 详情 / 下载 / 登录 / 用户数据 / 原站视图)记一次,
|
||||
// 按小时聚合,api / worker 多进程共用。站点页展示"最近 24 小时请求次数";保留 7 天,写入时顺手清理。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
create table site_request_count (
|
||||
site_id uuid not null references site(id) on delete cascade,
|
||||
hour timestamptz not null,
|
||||
count integer not null default 0 check (count >= 0),
|
||||
primary key (site_id, hour)
|
||||
);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["site_request_count"]));
|
||||
}
|
||||
@@ -119,8 +119,6 @@ const EXPECTED_TABLES = [
|
||||
"rate_limit_bucket",
|
||||
// C2-A PG 短期缓存(0042,UNLOGGED)
|
||||
"kv_cache",
|
||||
// 站点请求计数(0046,按小时聚合)
|
||||
"site_request_count",
|
||||
].sort();
|
||||
|
||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||
@@ -149,8 +147,6 @@ const NON_ENTITY_TABLES = [
|
||||
"rate_limit_bucket",
|
||||
// 缓存:(region, key) 主键
|
||||
"kv_cache",
|
||||
// 站点请求计数:(site_id, hour) 主键
|
||||
"site_request_count",
|
||||
];
|
||||
|
||||
describe.skipIf(skip)("schema migrations (real PG)", () => {
|
||||
|
||||
@@ -34,8 +34,16 @@ export interface MockCamofoxSite {
|
||||
/** 下载内容;缺省返回固定字节 */
|
||||
torrent?: (id: string) => Uint8Array;
|
||||
passkey?: string;
|
||||
/**
|
||||
* 登录态 Cookie(BR-2):注入的 Cookie 里 name 的值在 valid 中 → 该 profile 视为已登录;值不在其中 → 视为未登录(过期)。
|
||||
* 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。
|
||||
*/
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
}
|
||||
|
||||
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
||||
export type MockCookieApi = "plugin" | "upstream" | "none";
|
||||
|
||||
export const MOCK_VIEWPORT = { width: 1280, height: 800 } as const;
|
||||
export const MOCK_ROW_Y0 = 20;
|
||||
export const MOCK_ROW_H = 40;
|
||||
@@ -52,6 +60,21 @@ export interface MockCamofoxOptions {
|
||||
/** 视口(CSS 像素)与设备像素比:截图尺寸 = 视口 × DPR;元素行坐标按 CSS 像素。缺省 MOCK_VIEWPORT、DPR 1 */
|
||||
viewport?: { width: number; height: number };
|
||||
devicePixelRatio?: number;
|
||||
/** 缺省 plugin */
|
||||
cookieApi?: MockCookieApi;
|
||||
/** upstream 模式下上游导入端点认的口令(模拟 CAMOFOX_API_KEY);缺省不设 → 403 */
|
||||
cookieImportKey?: string;
|
||||
}
|
||||
|
||||
export interface MockCamofoxCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite?: string;
|
||||
}
|
||||
|
||||
export interface MockCamofoxTab {
|
||||
@@ -90,6 +113,8 @@ export type MockCamofox = MockHttp & {
|
||||
actions: MockCamofoxAction[];
|
||||
/** 已登录的 userId(profile) */
|
||||
loggedIn: Set<string>;
|
||||
/** 各 profile 的 Cookie(注入的 + 表单登录写入的) */
|
||||
cookieJars: Map<string, MockCamofoxCookie[]>;
|
||||
/** 模拟站点登录过期(全部 profile 或指定 profile) */
|
||||
expireLogin(userId?: string): void;
|
||||
/** 模拟上游回收标签页(空闲超时 / 重启) */
|
||||
@@ -298,7 +323,40 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const tabs = new Map<string, MockCamofoxTab>();
|
||||
const actions: MockCamofoxAction[] = [];
|
||||
const loggedIn = new Set<string>();
|
||||
const cookieJars = new Map<string, MockCamofoxCookie[]>();
|
||||
const cookieApi = opts.cookieApi ?? "plugin";
|
||||
const site = opts.site;
|
||||
const putCookie = (userId: string, c: MockCamofoxCookie) => {
|
||||
const jar = (cookieJars.get(userId) ?? []).filter(
|
||||
(x) => !(x.name === c.name && x.domain === c.domain && x.path === c.path),
|
||||
);
|
||||
jar.push(c);
|
||||
cookieJars.set(userId, jar);
|
||||
const sc = site?.sessionCookie;
|
||||
if (sc && c.name === sc.name && new URL(site.origin).hostname.endsWith(c.domain.replace(/^\./, ""))) {
|
||||
if (sc.valid.includes(c.value)) loggedIn.add(userId);
|
||||
else loggedIn.delete(userId);
|
||||
}
|
||||
};
|
||||
const importCookies = (userId: string, b: Body): MockReply => {
|
||||
const list = Array.isArray(b.cookies) ? (b.cookies as Record<string, unknown>[]) : null;
|
||||
if (!list) return err(400, "cookies must be an array");
|
||||
for (const c of list) {
|
||||
if (typeof c.name !== "string" || typeof c.value !== "string" || typeof c.domain !== "string")
|
||||
return err(400, "invalid cookie objects");
|
||||
putCookie(userId, {
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: typeof c.path === "string" ? c.path : "/",
|
||||
expires: typeof c.expires === "number" ? c.expires : -1,
|
||||
httpOnly: c.httpOnly === true,
|
||||
secure: c.secure === true,
|
||||
...(typeof c.sameSite === "string" ? { sameSite: c.sameSite } : {}),
|
||||
});
|
||||
}
|
||||
return { json: { ok: true, userId, count: list.length } };
|
||||
};
|
||||
const viewport = opts.viewport ?? MOCK_VIEWPORT;
|
||||
const dpr = opts.devicePixelRatio ?? 1;
|
||||
const loginPath = site?.loginPath ?? "/login.php";
|
||||
@@ -351,6 +409,19 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return;
|
||||
}
|
||||
loggedIn.add(t.userId);
|
||||
if (site.sessionCookie) {
|
||||
if (!site.sessionCookie.valid.includes(site.sessionCookie.fresh))
|
||||
site.sessionCookie.valid.push(site.sessionCookie.fresh);
|
||||
putCookie(t.userId, {
|
||||
name: site.sessionCookie.name,
|
||||
value: site.sessionCookie.fresh,
|
||||
domain: new URL(site.origin).hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 86400,
|
||||
httpOnly: true,
|
||||
secure: site.origin.startsWith("https:"),
|
||||
});
|
||||
}
|
||||
go(t, `${site.origin}${back}`);
|
||||
};
|
||||
|
||||
@@ -713,6 +784,32 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return { json: { downloads: list } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/umt\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, q, req, b) => {
|
||||
if (cookieApi !== "plugin") return err(404, "no route");
|
||||
const userId = decodeURIComponent(m[1]!);
|
||||
if (req.method === "POST") return importCookies(userId, b);
|
||||
const hosts = (q.urls ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((u) => new URL(u).hostname);
|
||||
const cookies = (cookieJars.get(userId) ?? []).filter((c) => {
|
||||
const d = c.domain.replace(/^\./, "");
|
||||
return hosts.some((h) => h === d || h.endsWith(`.${d}`));
|
||||
});
|
||||
return { json: { source: "live", cookies } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, _q, req, b) => {
|
||||
if (cookieApi === "none") return err(404, "no route");
|
||||
if (!opts.cookieImportKey || req.headers.authorization !== `Bearer ${opts.cookieImportKey}`)
|
||||
return err(403, "Forbidden");
|
||||
return importCookies(decodeURIComponent(m[1]!), b);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/sessions\/([^/]+)$/,
|
||||
(m) => {
|
||||
@@ -731,7 +828,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
port: opts.port,
|
||||
guard: (req) => {
|
||||
const path = new URL(req.url ?? "/", "http://mock").pathname;
|
||||
if (!opts.accessKey || path === "/health") return null;
|
||||
// 与上游一致:导入端点有自己的口令(CAMOFOX_API_KEY),不走访问凭据
|
||||
if (!opts.accessKey || path === "/health" || /^\/sessions\/[^/]+\/cookies$/.test(path)) return null;
|
||||
return req.headers.authorization === `Bearer ${opts.accessKey}` ? null : err(401, "Unauthorized");
|
||||
},
|
||||
},
|
||||
@@ -740,6 +838,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
tabs,
|
||||
actions,
|
||||
loggedIn,
|
||||
cookieJars,
|
||||
expireLogin(userId?: string) {
|
||||
if (userId) loggedIn.delete(userId);
|
||||
else loggedIn.clear();
|
||||
|
||||
@@ -45,38 +45,3 @@ export const HHANCLUB_DETAIL_PAGE = `<!DOCTYPE html><html><head><title>HHCLUB ::
|
||||
<div><a href="download.php?id=90002">下载种子</a></div>
|
||||
<div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div>
|
||||
</div></body></html>`;
|
||||
|
||||
/**
|
||||
* 2026-09-29 真实账号核对后补的最小脱敏片段(名称、ID、数值均已替换;不含 passkey 与用户信息)。
|
||||
* 核对到的真实行为:列表标题链接文本末尾带新种标记「[新]」(详情页标题没有);短剧分类 412;
|
||||
* 新种完成数为 0;无促销时没有 promotion-tag 元素;上传时间在 span[title] 里,文本是相对时间。
|
||||
* 下载链接带 passkey(这里用占位值)。
|
||||
*/
|
||||
export const HHANCLUB_LIST_PAGE_2026 = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
|
||||
<div id="user-info-panel"><a href="userdetails.php?id=7" class="VIP_Name">bob</a> <a href="logout.php">退出</a></div>
|
||||
<div class="torrent-table">
|
||||
<div class="torrent-table-sub-info">
|
||||
<div class="torrent-cat"><a href="?cat[]=412"><img src="pic/cattrans.gif" alt="短剧"/></a></div>
|
||||
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91001&hit=1">示例短剧 1080p WEB-DL H265 AAC-HHWEB[新]</a></div>
|
||||
<div class="torrent-info-text-small_name">示例短剧 | 全30集 | 1080p</div>
|
||||
<div class="torrent-manage"><a href="download.php?id=91001&passkey=0000000000000000">下载</a></div>
|
||||
<div class="torrent-info-text-added"><span title="2026-09-29 14:07:03">5分钟</span></div>
|
||||
<div class="torrent-info-text-size">185.38 MB</div>
|
||||
<div class="torrent-info-text-seeders">1</div>
|
||||
<div class="torrent-info-text-leechers">58</div>
|
||||
<div class="torrent-info-text-finished">0</div>
|
||||
</div>
|
||||
<div class="torrent-table-sub-info">
|
||||
<div class="torrent-cat"><a href="?cat[]=402"><img src="pic/cattrans.gif" alt="电视剧"/></a></div>
|
||||
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91002&hit=1">Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB</a></div>
|
||||
<div class="torrent-info-text-small_name">示例剧集 | 第12集 | 4K | 类型: 奇幻 [内嵌中字]</div>
|
||||
<div class="torrent-manage"><a href="download.php?id=91002&passkey=0000000000000000">下载</a></div>
|
||||
<div class="torrent-info-text-added"><span title="2026-09-29 12:03:28">2时8分钟</span></div>
|
||||
<div class="torrent-info-text-size">1.46 GB</div>
|
||||
<div class="torrent-info-text-seeders">131</div>
|
||||
<div class="torrent-info-text-leechers">5</div>
|
||||
<div class="torrent-info-text-finished">187</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=1">101-200</a></div>
|
||||
</body></html>`;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* BR-2 真机核验:把站点账号存的 Cookie 注入一个全新的 camofox profile → 打开站点首页一次 → 判定是否登录态
|
||||
* (地址不是登录页且页面有 a[href*=logout.php]),再经 umt-cookies 插件读回站点域名的 Cookie 个数。
|
||||
* 只输出布尔值与个数:不截图、不输出页面内容与 Cookie 值;对真实站点只有一次导航。
|
||||
* 用法(只读数据库;camofox 需带 umt-cookies 插件,见 docs/runbook/browser.md §9):
|
||||
* CAMOFOX_URL=http://127.0.0.1:49391 CAMOFOX_ACCESS_KEY=... DATABASE_URL=postgres://... SECRET_MASTER_KEY=... \
|
||||
* deno run -A scripts/browser-verify/cookie-real.ts <site key>
|
||||
* 密钥只从环境变量读,不写进文件。
|
||||
*/
|
||||
import {
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
headerFromCookies,
|
||||
parseCookieHeader,
|
||||
staticBrowserSecrets,
|
||||
} from "@umt/adapters";
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { createDb } from "@umt/db";
|
||||
import { createSecretStore } from "../../apps/server/src/modules/auth-settings/provided.ts";
|
||||
import { readAccountCookie } from "../../apps/server/src/modules/sources-identity/service/credentials.ts";
|
||||
|
||||
const env = (k: string) => {
|
||||
const v = Deno.env.get(k);
|
||||
if (!v) throw new Error(`缺少环境变量 ${k}`);
|
||||
return v;
|
||||
};
|
||||
const siteKey = Deno.args[0] ?? "hhanclub";
|
||||
const db = createDb({ connectionString: env("DATABASE_URL"), max: 2 });
|
||||
const secrets = createSecretStore({ db, masterKey: env("SECRET_MASTER_KEY") });
|
||||
const quiet = { info() {}, warn() {}, error() {}, debug() {} } as never;
|
||||
const report: Record<string, unknown> = { siteKey, at: new Date().toISOString() };
|
||||
try {
|
||||
const site = await db.selectFrom("site").selectAll().where("key", "=", siteKey).executeTakeFirstOrThrow();
|
||||
const acc = await db
|
||||
.selectFrom("site_account")
|
||||
.select(["id"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where("enabled", "=", true)
|
||||
.where("auth_kind", "in", ["cookie", "password"])
|
||||
.orderBy("created_at")
|
||||
.executeTakeFirstOrThrow();
|
||||
const cred = await readAccountCookie({ db, secrets, logger: quiet }, acc.id);
|
||||
if (!cred?.header) throw new Error("账号没有 Cookie");
|
||||
report.source = cred.source;
|
||||
report.storedCookieCount = parseCookieHeader(cred.header).size;
|
||||
|
||||
const PROVIDER = "00000000-0000-4000-8000-0000000000b2";
|
||||
const key = env("CAMOFOX_ACCESS_KEY");
|
||||
const p = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: env("CAMOFOX_URL"),
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: key }),
|
||||
});
|
||||
const host = new URL(site.base_url).hostname;
|
||||
const budget: NavigationBudget = { domainAllowlist: [host], timeoutMs: 45_000, maxRetries: 0, maxBytes: 512 * 1024 };
|
||||
// 全新 profile:证明登录态来自注入,不是 profile 里原有的
|
||||
const profileKey = `umt-br2-verify-${Date.now()}`;
|
||||
const t0 = Date.now();
|
||||
const opened = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: site.id,
|
||||
accountId: acc.id,
|
||||
profileKey,
|
||||
initialUrl: site.base_url,
|
||||
budget,
|
||||
cookies: cookiesFromHeader(cred.header, site.base_url),
|
||||
});
|
||||
report.inject = opened.cookies;
|
||||
report.openMs = Date.now() - t0;
|
||||
report.httpStatus = opened.navigation?.httpStatus ?? null;
|
||||
const info = p.sessionInfo(opened.session.id);
|
||||
report.loginPage = info?.loginPage ?? null;
|
||||
report.landedPath = opened.session.currentUrl ? new URL(opened.session.currentUrl).pathname : null;
|
||||
// 登录态标记:与站点定义 umt.loginSuccess(a[href='logout.php'])一致;只取布尔值
|
||||
const res = await fetch(`${env("CAMOFOX_URL")}/tabs/${encodeURIComponent(opened.tab.upstreamTabId)}/evaluate`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: profileKey,
|
||||
expression: `!!document.querySelector("a[href*='logout.php']")`,
|
||||
}),
|
||||
});
|
||||
report.logoutLinkPresent = res.ok
|
||||
? ((await res.json()) as { result?: unknown }).result === true
|
||||
: `HTTP ${res.status}`;
|
||||
report.loggedIn = report.logoutLinkPresent === true && report.loginPage === false;
|
||||
const back = await p.getCookies(profileKey, [site.base_url]);
|
||||
report.readBackCookieCount = back.length;
|
||||
report.readBackDomains = [...new Set(back.map((c) => c.domain))];
|
||||
const header = headerFromCookies(back, site.base_url);
|
||||
report.readBackContainsInjected = header
|
||||
? [...parseCookieHeader(cred.header).keys()].every((k) => parseCookieHeader(header).has(k))
|
||||
: false;
|
||||
await p.closeSession(opened.session.id, "verify_done");
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。",
|
||||
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。",
|
||||
"umt": {
|
||||
"categoryNames": {
|
||||
"401": "电影",
|
||||
@@ -12,37 +12,6 @@
|
||||
"410": "音乐",
|
||||
"411": "MV",
|
||||
"412": "短剧"
|
||||
},
|
||||
"version": "[email protected]+umt.1",
|
||||
"rateLimit": {
|
||||
"minIntervalMs": 3000,
|
||||
"burst": 2
|
||||
},
|
||||
"vipLevels": ["贵宾", "VIP"],
|
||||
"defaults": {
|
||||
"snapshotIntervalSec": 3600,
|
||||
"snapshotMaxPages": 1,
|
||||
"healthCheckIntervalSec": 21600,
|
||||
"userDataIntervalSec": 21600,
|
||||
"searchConcurrency": 1,
|
||||
"detailCacheSec": 86400
|
||||
}
|
||||
},
|
||||
"torrents": {
|
||||
"fields": {
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
|
||||
"filters": [
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["[新]", ""]
|
||||
},
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["(新)", ""]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user