From 28974d1e1a76e45db883564134956025f3b19ca4 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 15:25:07 +0800 Subject: [PATCH] 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 --- .../__tests__/browser-credentials.test.ts | 203 ++++++++++ .../src/modules/browser/__tests__/harness.ts | 14 +- apps/server/src/modules/browser/index.ts | 5 +- apps/server/src/modules/browser/service.ts | 205 +++++++++- .../src/modules/sources-identity/runtime.ts | 97 +---- .../src/modules/sources-identity/scheduler.ts | 35 +- .../sources-identity/service/account.ts | 18 +- .../sources-identity/service/credentials.ts | 154 ++++++++ .../sources-identity/service/snapshot.ts | 55 +-- .../sources-identity/service/workbench.ts | 16 +- apps/server/test/sources-identity/api.test.ts | 16 - .../credentials-browser.test.ts | 125 ++++++ .../sources-identity/detail-merge.test.ts | 48 --- apps/web/app/api/agent.ts | 28 +- apps/web/app/api/sites.ts | 24 -- .../components/agent/agent-conversation.tsx | 372 +++++++++++++++--- .../web/app/components/agent/agent-drawer.tsx | 157 ++++++-- .../components/agent/agent-helpers.test.ts | 76 +++- .../components/agent/agent-interaction.tsx | 9 +- .../app/components/agent/agent-run-panel.tsx | 356 ++++++++++++----- .../components/agent/agent-session-list.tsx | 338 ++++++++++++---- apps/web/app/components/agent/agent-store.ts | 80 +++- .../components/agent/agent-subagent-tree.tsx | 43 +- apps/web/app/components/agent/mock.ts | 10 +- .../app/components/agentui/tool-result.tsx | 35 +- apps/web/app/components/browser/coords.ts | 28 +- .../app/components/browser/desktop-view.tsx | 8 +- .../components/browser/site-origin-view.tsx | 8 +- apps/web/app/components/settings/sites.tsx | 26 -- .../app/components/sites/site-overview.tsx | 14 +- .../components/workbench/release-fields.ts | 91 ----- .../app/components/workbench/release-list.tsx | 164 ++------ apps/web/app/routes/console/agent.$runId.tsx | 25 +- apps/web/app/routes/console/agent.tsx | 24 +- apps/web/app/routes/console/sites.$siteId.tsx | 16 +- apps/web/scripts/smoke.mjs | 30 ++ docker/browser-desktop/Dockerfile | 3 + .../s6-overlay/s6-rc.d/init-umt-desktop/run | 2 + .../etc/s6-overlay/s6-rc.d/svc-camofox/run | 2 + .../opt/camofox/plugins/umt-cookies/index.js | 105 +++++ docs/runbook/browser.md | 62 +++ .../adapters/src/browser/camofox/client.ts | 112 +++++- packages/adapters/src/browser/cookies.test.ts | 139 +++++++ packages/adapters/src/browser/cookies.ts | 96 +++++ packages/adapters/src/browser/index.ts | 1 + packages/adapters/src/browser/provider.ts | 17 + packages/adapters/src/sites/README.md | 23 +- .../src/sites/definitions/definitions.test.ts | 46 +-- .../src/sites/definitions/hhanclub.json | 25 +- .../adapters/src/sites/definitions/schema.ts | 10 - packages/adapters/src/sites/spider/schema.ts | 20 - packages/contracts/src/browser.ts | 43 ++ .../db/migrations/0046_site_request_count.ts | 25 -- packages/db/test/schema.test.ts | 4 - packages/testkit/src/browser/mock-camofox.ts | 101 ++++- .../sites/fixtures/definitions/hhanclub.ts | 35 -- scripts/browser-verify/cookie-real.ts | 98 +++++ .../mp-sync/movieclaw-overrides/hhanclub.json | 33 +- 58 files changed, 2843 insertions(+), 1112 deletions(-) create mode 100644 apps/server/src/modules/browser/__tests__/browser-credentials.test.ts create mode 100644 apps/server/src/modules/sources-identity/service/credentials.ts create mode 100644 apps/server/test/sources-identity/credentials-browser.test.ts delete mode 100644 apps/server/test/sources-identity/detail-merge.test.ts create mode 100644 docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js create mode 100644 packages/adapters/src/browser/cookies.test.ts create mode 100644 packages/adapters/src/browser/cookies.ts delete mode 100644 packages/db/migrations/0046_site_request_count.ts create mode 100644 scripts/browser-verify/cookie-real.ts diff --git a/apps/server/src/modules/browser/__tests__/browser-credentials.test.ts b/apps/server/src/modules/browser/__tests__/browser-credentials.test.ts new file mode 100644 index 00000000..82fb02e2 --- /dev/null +++ b/apps/server/src/modules/browser/__tests__/browser-credentials.test.ts @@ -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"); + }); +}); diff --git a/apps/server/src/modules/browser/__tests__/harness.ts b/apps/server/src/modules/browser/__tests__/harness.ts index 1bbab667..6d265c53 100644 --- a/apps/server/src/modules/browser/__tests__/harness.ts +++ b/apps/server/src/modules/browser/__tests__/harness.ts @@ -53,11 +53,23 @@ export interface BrowserHarness { close(): Promise; } -export async function createBrowserHarness(name: string, opts: { captcha?: boolean } = {}): Promise { +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 { 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, diff --git a/apps/server/src/modules/browser/index.ts b/apps/server/src/modules/browser/index.ts index 06aa42d3..56cb43a1 100644 --- a/apps/server/src/modules/browser/index.ts +++ b/apps/server/src/modules/browser/index.ts @@ -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)); diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index 36ae036d..26c1700a 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -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>; 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 => ({ + 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 { + 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) => { + 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 { 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(); + 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 { await this.rateLimit(site); @@ -1350,7 +1551,7 @@ const REASON_TEXT: Record = { challenge: "页面需要人工验证", two_factor: "需要二次验证或本人设备确认", challenge_unsolved: "自动处理页面挑战未成功", - login_required: "登录已失效", + login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效", lease_expired: "人工接管超时未交还", agent_request: "Agent 请求人工处理", human_released_waiting: "人工交还时选择继续等待", diff --git a/apps/server/src/modules/sources-identity/runtime.ts b/apps/server/src/modules/sources-identity/runtime.ts index 2e66eed9..40d6d8f6 100644 --- a/apps/server/src/modules/sources-identity/runtime.ts +++ b/apps/server/src/modules/sources-identity/runtime.ts @@ -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:);进程内先串行排队 - 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 { - 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 { - 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 }; /** diff --git a/apps/server/src/modules/sources-identity/scheduler.ts b/apps/server/src/modules/sources-identity/scheduler.ts index a7335501..29ef66b8 100644 --- a/apps/server/src/modules/sources-identity/scheduler.ts +++ b/apps/server/src/modules/sources-identity/scheduler.ts @@ -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 { export async function tickSnapshots(rt: SourcesRuntime): Promise { 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; } /** diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index 5cb2aaa2..60224c9c 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -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 { const sites = await rt.ctx.db .selectFrom("site") @@ -899,11 +911,7 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise { .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++; diff --git a/apps/server/src/modules/sources-identity/service/credentials.ts b/apps/server/src/modules/sources-identity/service/credentials.ts new file mode 100644 index 00000000..49391e9b --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/credentials.ts @@ -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; + +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 => + v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; + +/** 主 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 { + 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 { + 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; + +const readers = new WeakMap(); + +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, + current: string | null, +): Promise { + 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; +} diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index ab3c5046..42854bce 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -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 })._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 ?? []), - }; -} diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index c26a7c88..b89e3e8e 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -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>; failedUntil: Map; - /** 按站点串行的实时搜索(站点访问默认 searchConcurrency = 1 时) */ - siteChain: Map>; } const liveStates = new WeakMap(); 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 }; } diff --git a/apps/server/test/sources-identity/api.test.ts b/apps/server/test/sources-identity/api.test.ts index ddb7e262..50dbb7b9 100644 --- a/apps/server/test/sources-identity/api.test.ts +++ b/apps/server/test/sources-identity/api.test.ts @@ -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); diff --git a/apps/server/test/sources-identity/credentials-browser.test.ts b/apps/server/test/sources-identity/credentials-browser.test.ts new file mode 100644 index 00000000..aeb68e4a --- /dev/null +++ b/apps/server/test/sources-identity/credentials-browser.test.ts @@ -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 => { + 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 }); + }); +}); diff --git a/apps/server/test/sources-identity/detail-merge.test.ts b/apps/server/test/sources-identity/detail-merge.test.ts deleted file mode 100644 index 75732aca..00000000 --- a/apps/server/test/sources-identity/detail-merge.test.ts +++ /dev/null @@ -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: "电视剧" } }); - }); -}); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 93b758a0..ed991cea 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -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 = { + 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) => diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index e448799c..4a6db6ea 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -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(); diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index c58ea56e..a9863c31 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -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): 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; + 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 ? 恢复补记 : null} /> } - summary={call.outputSummary ?? undefined} + summary={toolSummary(call) ?? undefined} > {call.inputSummary ? {call.inputSummary} : 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 | 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 ( +
+ + +
+ {entries.map((e) => ( +
{e.node}
+ ))} +
+
+
+ ); +} + +/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */ 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 ? ( +
+ {node} +
+ ) : ( + node + ); switch (it.kind) { case "text": - node = ( - + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap( {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {streaming ? it.text : } - - - ); + , + ), + }); break; case "thinking": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: !streaming, + label: "思考", + node: wrap(), + }); break; - case "tool": - node = ( - - - - ); + 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(), + }); break; + } case "compaction": - node = ( - - - 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} - {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} - - ); + entries.push({ + key: it.key, + at: it.at, + role: "system", + foldable: false, + node: ( + + + 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} + {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} + + ), + }); break; case "options": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap(), + }); break; case "approval": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap(), + }); break; case "end": - node = it.ok ? null : ; + if (!it.ok) + entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: }); 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: , }); - rows.sort((a, b) => a.at.localeCompare(b.at)); + 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" ? ( + + + {run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` + : run.status === "queued" + ? "排队中…" + : "正在处理…"} + + ) : null; + const lastBlock = blocks[blocks.length - 1]; + const typingInTurn = typing && lastBlock?.kind === "turn"; return ( -
+
- {rows.map((r) => (r.node ?
{r.node}
: null))} - {active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? ( - - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} - - - ) : null} + {blocks.map((b) => + b.kind === "single" ? ( +
{b.entry.node}
+ ) : ( + + + + + + + Agent + + + {b.parts.map((p) => + p.kind === "fold" ? ( + + ) : ( +
+ {p.entry.node} +
+ ), + )} + {typingInTurn && b === lastBlock ? typing : null} +
+
+ ), + )} + {typing && !typingInTurn ? {typing} : null} {after}
); @@ -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 ( -
+
? "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 ( +
+
+ {children} +
+ +
+ ); +} + /** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ export function FinalReport({ run, @@ -719,15 +965,17 @@ export function FinalReport({ const imported = report?.organization.filter((o) => o.targetPath) ?? []; return ( -
+

模型说的(未经核查)

- - {said ? : "(没有文字汇报)"} - + + + {said ? : "(没有文字汇报)"} + +

仅供参考,不作为完成依据。

diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index f7f00e50..a846a22b 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -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 ( +
+ {TABS.map((t) => ( + + ))} +
+ ); +} + +/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */ +function HistoryRunView({ runId }: { runId: string }) { + const run = useAgentRun(runId); + const mock = agentMockEnabled(); + return ( +
+
+ +

+ {run.data ? splitContextGoal(run.data.run.goal).text : ""} +

+ +
+ + + +
+ ); +} + /** * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; + * 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。 * 运行中可最小化到右下角小窗,流式输出继续。 * 挂载:在 console layout 里渲染一次 ;顶栏放 。 * 页面内打开: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(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(); }} > - + Agent - + {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"} ({agentShortcutLabel()} 开关,Esc 关闭) - {context ? : null} + {context && !history ? : null} + - {runId ? ( + {runId && !history ? ( ) : null} -
- {runId ? ( - +
+ {history ? ( + historyRunId ? ( + + ) : ( + + ) + ) : runId ? ( + + + ) : ( )}
-
- {runId ? ( - <> - + + + ) : ( + - - - ) : ( - - )} -
+ )} +
+ )} {minimized && 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()) + } > diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index f1858ee5..9e44ea12 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index acd83a7f..b181ab28 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -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"} /> ); } diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index be3d0ee7..fe820833 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -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 ( +
+
+ + {isProposeFirst(run) ? 先看方案再执行 : null} + {agentMockEnabled() ? ( + + + mock 数据 + + ) : null} +
+

+ {TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)} + {run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""} +

+
+ ); +} + +/** + * 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。 + * 不 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 ( +
+ + {steps.length ? ( +
+
+
+ ) : null} + +
+ + + +
+

预算与用量

+ +
+
+
+
+ ); +} + +/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */ +function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) { + const create = useCreateAgentRun(); + const [proposeFirst, setProposeFirst] = useState(false); + return ( +
+ + +
+ ); +} + +/** + * 一次运行。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,101 +353,122 @@ export function AgentRunPanel({ }, ); + const column = "mx-auto w-full max-w-[88ch]"; + return ( -
+
-
- - {proposeFirst ? 先看方案再执行 : null} - - {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)} - {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""} - - {agentMockEnabled() ? ( - - - mock 数据 - - ) : null} -
- -
- -
- - - - - - {streaming && sseStatus === "reconnecting" ? ( -
- - 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 -
- ) : null} - {live.error ? ( -
- 事件读取失败:{live.error} - -
- ) : null} - - - + ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} - after={ - <> - {approvals.items - .filter((a) => !inline.has(a.id)) - .map((a) => ( - - ))} - {finished ? : null} - - } + active={streaming || r.status === "waiting"} + className={cn(column, !compact && "lg:hidden")} /> - -
- setConfirmStop(true)} - stopLabel={abort.isPending ? "停止中…" : "停止"} - minRows={1} - maxRows={6} - maxLength={8000} - aria-label="追加消息" - placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"} - /> -

- {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") - ? `${r.summary}:在上面的卡片里处理,会话随即继续。` - : "追加的消息在本轮结束后读取。"}{" "} - Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。 -

+ {streaming && sseStatus === "reconnecting" ? ( +
+ + 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 +
+ ) : null} + {live.error ? ( +
+ 事件读取失败:{live.error} + +
+ ) : null} + + + ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} + after={ + <> + {approvals.items + .filter((a) => !inline.has(a.id)) + .map((a) => ( + + ))} + {finished ? : null} + + } + /> + + +
+ setConfirmStop(true)} + stopLabel={abort.isPending ? "停止中…" : "停止"} + minRows={1} + maxRows={6} + maxLength={8000} + aria-label={ended ? "追加消息续跑" : "追加消息"} + placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"} + submitLabel={ended ? "续跑" : "发送"} + toolbar={ + ended ? ( + + ) : isProposeFirst(r) ? ( + 先看方案再执行 + ) : null + } + /> +

+ {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") + ? `${r.summary}:在会话里的卡片上处理,会话随即继续。` + : ended + ? "续跑沿用本会话的上下文与预算。" + : "追加的消息在本轮结束后读取。"}{" "} + Enter 发送,Shift+Enter 换行。 +

+
+ {compact ? null : ( + + )} + ({ 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, + f: Pick, + 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) { const parts = [`${r.toolCallCount} 次调用`]; if (r.usage.totalTokens !== undefined) parts.push( @@ -31,104 +69,232 @@ function usageText(r: AgentRunView) { return parts.join(" · "); } -/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */ +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 ( + + ); +} + +/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ +const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 }; + +function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) { + const goal = splitContextGoal(r.goal); + return ( + <> +
+

+ {goal.text} +

+ {children} +
+
+ + {TRIGGER_LABEL[r.trigger]} + {r.scope.proposeFirst === true ? · 先看方案 : null} + +
+

+ {goal.context ? `${goal.context.split(" · ")[0]} · ` : ""} + {usageText(r)} +

+ + ); +} + +/** + * 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。 + * - hrefFor:会话页左栏,点一条跳到该会话页。 + * - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。 + */ export function AgentSessionList({ activeId, - status, - onStatusChange, + hrefFor, + onOpen, + remember = false, className, }: { activeId?: string; - status: "" | AgentRunStatus; - onStatusChange: (s: "" | AgentRunStatus) => void; + hrefFor?: (id: string) => string; + onOpen?: (id: string) => void; + remember?: boolean; className?: string; }) { - const runs = useAgentRuns(status || undefined); - const mock = agentMockEnabled(); - const rows = runs.data?.pages.flatMap((p) => p.items) ?? []; + const [f, setF] = useState(remember ? drawerMemory.filters : EMPTY_FILTERS); + const set = (patch: Partial) => + 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(null); + const sentinel = useRef(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 ( diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index 7a10eb2b..d4b1826f 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -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()); } /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ diff --git a/apps/web/app/components/agent/agent-subagent-tree.tsx b/apps/web/app/components/agent/agent-subagent-tree.tsx index 48dd03b1..a22ce036 100644 --- a/apps/web/app/components/agent/agent-subagent-tree.tsx +++ b/apps/web/app/components/agent/agent-subagent-tree.tsx @@ -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 ( + + ); + return ( + + {children} + + ); +} + function budgetText(b: Record) { const parts: string[] = []; if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`); @@ -49,12 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map )}
- - {splitContextGoal(node.goal).text} - + {splitContextGoal(node.goal).text}
@@ -82,7 +98,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map(); @@ -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 ( -
+
{parent ? (

父会话

- - {splitContextGoal(parent.goal).text} - + {splitContextGoal(parent.goal).text}

第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "} {budgetText(run.budget as Record)} @@ -115,7 +126,7 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) { ) : null} {direct.length ? (

-

+

子会话 {children.length} 个{familyUsage ? : null}

diff --git a/apps/web/app/components/agent/mock.ts b/apps/web/app/components/agent/mock.ts index 3aeae6c2..08cb2cbd 100644 --- a/apps/web/app/components/agent/mock.ts +++ b/apps/web/app/components/agent/mock.ts @@ -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 } }, { diff --git a/apps/web/app/components/agentui/tool-result.tsx b/apps/web/app/components/agentui/tool-result.tsx index 360226b8..1aed2aea 100644 --- a/apps/web/app/components/agentui/tool-result.tsx +++ b/apps/web/app/components/agentui/tool-result.tsx @@ -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" > {icon ?? } - - {title} - {tool} - {meta ? {meta} : null} - + {/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */} + {title} {st.label} - + {meta ? {meta} : null} + + {!open && summary ? summary : null} + + - {badges ?
{badges}
: null} - {!open && summary ? ( -

{summary}

- ) : null} -
{children}
+
+
+ {tool} + {badges} +
+ {children} +
); diff --git a/apps/web/app/components/browser/coords.ts b/apps/web/app/components/browser/coords.ts index 4f66f99d..582dae94 100644 --- a/apps/web/app/components/browser/coords.ts +++ b/apps/web/app/components/browser/coords.ts @@ -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 = { 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 = { 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 }; + } +} diff --git a/apps/web/app/components/browser/desktop-view.tsx b/apps/web/app/components/browser/desktop-view.tsx index a7b6040f..2ec8d63e 100644 --- a/apps/web/app/components/browser/desktop-view.tsx +++ b/apps/web/app/components/browser/desktop-view.tsx @@ -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})` : ""} {waiting ? {waiting} : null} + {cred ? ( + + {cred.text} + + ) : null}
{human && mine ? ( ); } - -/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */ -function InventoryLine({ - release, - entry, - loading, -}: { - release: WorkbenchRelease; - entry: InventoryEntry | undefined; - loading: boolean; -}) { - if (!release.entity && release.resolutionStatus !== "ambiguous") return null; - return ( -
- -
- ); -} diff --git a/apps/web/app/routes/console/agent.$runId.tsx b/apps/web/app/routes/console/agent.$runId.tsx index 031dd589..e918f9f3 100644 --- a/apps/web/app/routes/console/agent.$runId.tsx +++ b/apps/web/app/routes/console/agent.$runId.tsx @@ -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 ( <> {run.data.run.goal} : "加载中…"} + description={ + run.data ? ( + + {splitContextGoal(run.data.run.goal).text} + + ) : ( + "加载中…" + ) + } actions={ } /> -
-