Merge pull request #1270 from pikasTech/fix/issue-1263-1264-workbench-probe

fix(web): 归一化 Workbench session 标题与 probe 证据
This commit is contained in:
Lyon
2026-06-15 19:12:33 +08:00
committed by GitHub
4 changed files with 146 additions and 22 deletions
+92 -20
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import fs from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -99,6 +100,7 @@ export async function runProbe(args) {
browser = await launchChromium(chromium);
const context = await browser.newContext({ viewport: { width, height } });
const page = await context.newPage();
const bootstrapRoutes = observeBootstrapRoutes(page);
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
await waitForAuthBootstrap(page, args, actions);
await maybeLogin(page, args, actions);
@@ -110,7 +112,7 @@ export async function runProbe(args) {
if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions);
await waitForMessageCards(page, args, actions);
await page.screenshot({ path: screenshotPath, fullPage: true });
const dom = await collectDomEvidence(page);
const dom = await collectDomEvidence(page, args);
const promptValidation = validatePromptFinalResponse(dom, args);
result = {
ok: true,
@@ -123,14 +125,15 @@ export async function runProbe(args) {
viewport: { width, height },
browser: browser.hwlabLaunchPlan ?? null,
actions,
bootstrapRoutes: summarizeBootstrapRoutes(bootstrapRoutes),
dom,
promptValidation,
artifacts: { reportPath, screenshotPath },
safety: {
repoOwnedLauncher: true,
directChromiumLaunch: false,
projectId: args.projectId,
conversationSelected: Boolean(args.conversationId),
projectId: args.projectId,
conversationSelected: Boolean(args.conversationId),
freshSessionRequested: args.freshSession,
promptSubmitted: Boolean(args.message),
cancelRunningRequested: args.cancelRunning
@@ -154,6 +157,13 @@ export async function runProbe(args) {
}
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
result.artifacts = {
...(result.artifacts ?? {}),
reportPath,
reportSha256: await fileSha256(reportPath),
screenshotPath,
screenshotSha256: await fileSha256(screenshotPath)
};
return result;
}
@@ -297,26 +307,14 @@ async function maybeLogin(page, args, actions) {
actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
const cardInputs = page.locator(".login-card input");
if ((await cardInputs.count().catch(() => 0)) < 2) {
actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode: "login-card", inputCount: await cardInputs.count().catch(() => 0), authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
await cardInputs.nth(0).fill(args.user);
await cardInputs.nth(1).fill(args.pass);
await page.locator(".login-card button[type=submit]").click();
actions.push({ action: "login", user: args.user, selectorMode: "login-card" });
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
const finalUrl = new URL(page.url());
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
throw new Error("login credentials leaked into URL query");
}
await submitCurrentLoginForm(page, args, actions, "current-autocomplete");
}
async function waitForLoginSurface(page, args) {
const handle = await page.waitForFunction(() => {
if (document.querySelector("#workspace")) return "workspace";
if (document.querySelector("#login-username")) return "legacy-id";
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
if (document.querySelector(".login-card input")) return "login-card";
return "";
}, null, { timeout: args.timeoutMs }).catch(() => null);
@@ -324,11 +322,38 @@ async function waitForLoginSurface(page, args) {
return handle.jsonValue().catch(() => null);
}
async function submitCurrentLoginForm(page, args, actions, selectorMode) {
const username = page.locator('input[autocomplete="username"], .login-card input').first();
const password = page.locator('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
const submit = page.locator('.login-card button[type="submit"], button[type="submit"]').first();
const inputCount = await page.locator(".login-card input, input[autocomplete=\"username\"], input[autocomplete=\"current-password\"]").count().catch(() => 0);
if (!(await username.isVisible({ timeout: 5000 }).catch(() => false)) || !(await password.isVisible({ timeout: 5000 }).catch(() => false))) {
actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode, inputCount, authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
await username.fill(args.user);
await password.fill(args.pass);
await Promise.allSettled([
page.waitForURL(/\/workbench/u, { timeout: Math.min(args.timeoutMs, 15000) }),
submit.click()
]);
actions.push({ action: "login", user: args.user, selectorMode });
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
assertNoCredentialLeak(page.url());
}
function assertNoCredentialLeak(value) {
const finalUrl = new URL(value);
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
throw new Error("login credentials leaked into URL query");
}
}
async function waitForAuthBootstrap(page, args, actions) {
const timeout = Math.min(args.timeoutMs, 12000);
const settled = await page.waitForFunction(() => {
const authState = document.body.getAttribute("data-auth-state");
const loginVisible = Boolean(document.querySelector("#login-username"));
const loginVisible = Boolean(document.querySelector("#login-username") || document.querySelector('input[autocomplete="username"]') || document.querySelector(".login-card input"));
const workspaceVisible = Boolean(document.querySelector("#workspace"));
return authState !== "checking" || loginVisible || workspaceVisible;
}, null, { timeout }).catch(() => null);
@@ -560,11 +585,18 @@ async function collectLatestAgentMessageState(page) {
});
}
async function collectDomEvidence(page) {
return page.evaluate(() => {
async function collectDomEvidence(page, args) {
return page.evaluate(async ({ projectId }) => {
const workspace = document.querySelector("#workspace");
const list = document.querySelector("#conversation-list");
const cards = [...document.querySelectorAll(".message-card")];
const conversationResponse = await fetch(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" }).catch(() => null);
const conversationBody = conversationResponse ? await conversationResponse.json().catch(() => null) : null;
const conversations = Array.isArray(conversationBody?.conversations) ? conversationBody.conversations : [];
const sessionTabs = [...document.querySelectorAll("#session-tabs .session-tab")];
const activeSessionTab = sessionTabs.find((item) => item.getAttribute("data-active") === "true") ?? null;
const sessionIds = conversations.map((item) => item?.sessionId).filter((value) => typeof value === "string" && value).slice(0, 8);
const traceIds = conversations.map((item) => item?.lastTraceId).filter((value) => typeof value === "string" && value).slice(0, 8);
const scroll = workspace ? {
scrollTop: Math.round(workspace.scrollTop),
scrollHeight: Math.round(workspace.scrollHeight),
@@ -582,6 +614,16 @@ async function collectDomEvidence(page) {
conversationList: Boolean(list),
sessionCreate: Boolean(document.querySelector("#session-create"))
},
sessionRail: {
tabCount: sessionTabs.length,
activeTitle: activeSessionTab?.querySelector("strong")?.textContent?.trim() ?? null,
objectTitleCount: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").filter((text) => text.includes("[object Object]")).length,
titles: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").slice(0, 8),
sessionIds,
traceIds,
conversationsStatus: conversationResponse?.status ?? null,
conversationsCount: conversations.length
},
messageCount: cards.length,
messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system") ?? "unknown"),
messages: cards.map((card) => {
@@ -630,7 +672,37 @@ async function collectDomEvidence(page) {
scroll,
textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
};
}, { projectId: args.projectId });
}
function observeBootstrapRoutes(page) {
const routes = [];
page.on("request", (request) => {
try {
const url = new URL(request.url());
if (!["/auth/bootstrap", "/auth/session", "/auth/workspace-bootstrap", "/v1/workbench/workspace"].includes(url.pathname)) return;
routes.push({ method: request.method(), path: url.pathname });
} catch {
// Ignore malformed browser-internal URLs.
}
});
return routes;
}
function summarizeBootstrapRoutes(routes) {
const paths = routes.map((route) => route.path);
return {
authBootstrapRequested: paths.includes("/auth/bootstrap"),
authSessionRequested: paths.includes("/auth/session"),
workbenchWorkspaceRequested: paths.includes("/v1/workbench/workspace"),
legacyWorkspaceBootstrapRequested: paths.includes("/auth/workspace-bootstrap"),
routes: routes.slice(0, 20)
};
}
async function fileSha256(file) {
if (!file || !fs.existsSync(file)) return null;
return createHash("sha256").update(await readFile(file)).digest("hex");
}
function parseViewport(value) {
@@ -46,6 +46,16 @@ test("R2 session tabs expose label, model metadata source, count and active stat
assert.match(tabs[0]?.subtitle ?? "", /trace trc_done/u);
});
test("R2 session tab labels normalize object previews and reject object placeholders", () => {
const tabs = sortSessionTabs([
{ conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" },
{ conversationId: "cnv_literal", sessionId: "ses_literal", firstUserMessagePreview: "[object Object]", updatedAt: "2026-01-02T00:00:00.000Z" }
], null);
assert.equal(tabs[0]?.label, "对象预览标题");
assert.equal(tabs[1]?.label, "Session ses_lite...");
assert.ok(tabs.every((tab) => !tab.label.includes("[object Object]")));
});
test("R2 provider options follow catalog and keep selected fallback", () => {
const catalog = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex-api", configured: true }, { profile: "deepseek", configured: false }] }, "minimax-m3");
assert.deepEqual(catalog.map((item) => item.value), ["deepseek", "codex-api", "minimax-m3"]);
@@ -65,7 +65,7 @@ function readWidth(): number {
<div class="session-model-channel" id="session-model-channel">模型通道{{ workbench.providerProfile }}</div>
<div class="session-list" id="session-tabs">
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" type="button" @click="workbench.selectConversation(tab)">
<strong>{{ tab.label || tab.title || tab.name || tab.firstUserMessagePreview || tab.conversationId }}</strong>
<strong>{{ tab.label || tab.conversationId }}</strong>
<span>{{ tab.sessionId || "未绑定 session" }}</span>
<span>{{ tab.subtitle }}</span>
<small>{{ formatRelativeTime(tab.updatedAt || tab.startedAt) }}</small>
@@ -32,6 +32,7 @@ export interface ProviderProfileOption {
export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1";
export const RECENT_DRAFTS_LIMIT = 8;
const OBJECT_OBJECT_TEXT = "[object Object]";
const BUILTIN_PROVIDER_PROFILE_ORDER = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = Object.freeze({
@@ -91,7 +92,17 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
const updatedAt = firstNonEmptyString(conversation.updatedAt, conversation.snapshot?.updatedAt, conversation.startedAt);
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
const preview = firstNonEmptyString(conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, conversation.messages?.find((message) => message.role === "user")?.text);
const userMessage = conversation.messages?.find((message) => message.role === "user");
const preview = firstReadableText(
conversation.firstUserMessagePreview,
conversation.snapshot?.firstUserMessagePreview,
conversation.userPreview,
conversation.title,
conversation.name,
userMessage?.text,
userMessage?.content,
userMessage?.title
);
return {
...conversation,
key: sessionId ?? conversationId,
@@ -207,9 +218,40 @@ function compareProviderProfileOptions(left: ProviderProfileOption, right: Provi
function truncateLabel(value: string | null): string | null {
if (!value) return null;
const text = value.replace(/\s+/gu, " ").trim();
if (!text || text === OBJECT_OBJECT_TEXT) return null;
return text.length <= 36 ? text : `${text.slice(0, 36).trimEnd()}...`;
}
function firstReadableText(...values: unknown[]): string | null {
for (const value of values) {
const text = readableText(value);
if (text) return text;
}
return null;
}
function readableText(value: unknown): string | null {
if (typeof value === "string") {
const text = value.replace(/\s+/gu, " ").trim();
return text && text !== OBJECT_OBJECT_TEXT ? text : null;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return firstReadableText(...value);
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
return firstReadableText(
record.text,
record.content,
record.message,
record.summary,
record.preview,
record.prompt,
record.title,
record.name,
record.markdown
);
}
function shortToken(value: string | null | undefined, size: number): string {
const text = String(value ?? "");
if (text.length <= size) return text;