fix: polish hall auth, layout, smoke, and usage channels

Fixes #74

Fixes #71

Fixes #73

Refs #70
This commit is contained in:
OpenClaw Local
2026-04-01 17:49:16 +02:00
parent 8f866ad258
commit 0631b5234c
11 changed files with 188 additions and 13 deletions
+70
View File
@@ -0,0 +1,70 @@
"use strict";
const { execFileSync } = require("node:child_process");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
function npmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function installPlaywrightPackageIfMissing() {
try {
return {
packageJsonPath: require.resolve("playwright/package.json"),
installedPackage: false,
};
} catch {
console.log("[smoke] playwright package missing; installing a local temporary copy...");
execFileSync(npmCommand(), ["install", "--no-save", "playwright"], {
cwd: ROOT,
stdio: "inherit",
});
return {
packageJsonPath: require.resolve("playwright/package.json"),
installedPackage: true,
};
}
}
function shouldInstallChromium(error) {
const message = String(error instanceof Error ? error.message : error || "");
return (
message.includes("Executable doesn't exist") ||
message.includes("browserType.launch") ||
message.includes("Please run the following command") ||
message.includes("playwright install")
);
}
async function ensurePlaywrightChromium() {
const { packageJsonPath, installedPackage } = installPlaywrightPackageIfMissing();
const playwright = require("playwright");
let browser = null;
try {
browser = await playwright.chromium.launch({ headless: true });
await browser.close();
return { installedPackage, installedBrowser: false };
} catch (error) {
if (browser) {
try { await browser.close(); } catch {}
}
if (!shouldInstallChromium(error)) throw error;
}
console.log("[smoke] chromium browser missing; installing Playwright Chromium...");
const cliPath = path.join(path.dirname(packageJsonPath), "cli.js");
execFileSync(process.execPath, [cliPath, "install", "chromium"], {
cwd: ROOT,
stdio: "inherit",
});
browser = await playwright.chromium.launch({ headless: true });
await browser.close();
return { installedPackage, installedBrowser: true };
}
module.exports = {
ensurePlaywrightChromium,
};
+12
View File
@@ -23,6 +23,17 @@ type SeededTask = {
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function ensureBrowserAutomation(): Promise<void> {
const helperModule = await import("./ensure-playwright.js");
const ensurePlaywrightChromium =
helperModule.ensurePlaywrightChromium ??
(helperModule.default as { ensurePlaywrightChromium?: () => Promise<unknown> } | undefined)?.ensurePlaywrightChromium;
if (typeof ensurePlaywrightChromium !== "function") {
throw new Error("Failed to load Playwright bootstrap helper.");
}
await ensurePlaywrightChromium();
}
async function waitForServer(baseUrl: string): Promise<void> {
const deadline = Date.now() + SERVER_TIMEOUT_MS;
let lastError: unknown;
@@ -513,6 +524,7 @@ function startServer(): ChildProcessWithoutNullStreams {
}
async function runBrowserSmoke(baseUrl: string, firstTask: SeededTask, secondTask: SeededTask, thirdTask: SeededTask, fourthTask: SeededTask, fifthTask: SeededTask, sixthTask: SeededTask, seventhTask: SeededTask, eighthTask: SeededTask): Promise<void> {
await ensureBrowserAutomation();
const { chromium } = await import("playwright");
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
+30 -4
View File
@@ -1272,7 +1272,7 @@ function buildSessionTypeBreakdownFromSessionContexts(
const uniqueSessions = dedupeSessionContexts(contexts);
if (uniqueSessions.length === 0) return [];
const order = ["Cron", "Discord", "Telegram", "Main/内部会话"] as const;
const order = ["Cron", "Discord", "Telegram", "飞书", "微信", "Main/内部会话"] as const;
const buckets = new Map<string, UsageBreakdownRow>(
order.map((label) => [
label,
@@ -1307,7 +1307,7 @@ function buildSessionTypeBreakdownFromRuntimeEvents(
sourceStatus: ConnectionStatus,
): UsageBreakdownRow[] {
if (sourceStatus === "not_connected" || events.length === 0) return [];
const order = ["Cron", "Discord", "Telegram", "Main/内部会话"] as const;
const order = ["Cron", "Discord", "Telegram", "飞书", "微信", "Main/内部会话"] as const;
const buckets = new Map<string, UsageBreakdownRow>(
order.map((label) => [
label,
@@ -1505,7 +1505,9 @@ function dedupeSessionContexts(contexts: RuntimeSessionContext[]): RuntimeSessio
return [...byIdentity.values()];
}
function classifySessionTypeLabel(context: RuntimeSessionContext): "Cron" | "Discord" | "Telegram" | "Main/内部会话" {
function classifySessionTypeLabel(
context: RuntimeSessionContext,
): "Cron" | "Discord" | "Telegram" | "飞书" | "微信" | "Main/内部会话" {
const key = context.sessionKey.trim().toLowerCase();
const channel = context.channel?.trim().toLowerCase() ?? "";
const surface = context.surface?.trim().toLowerCase() ?? "";
@@ -1529,15 +1531,39 @@ function classifySessionTypeLabel(context: RuntimeSessionContext): "Cron" | "Dis
) {
return "Telegram";
}
if (
key.includes(":feishu:") ||
key.startsWith("feishu:") ||
channel.includes("feishu") ||
surface.includes("feishu")
) {
return "飞书";
}
if (
key.includes(":weixin:") ||
key.startsWith("weixin:") ||
key.includes(":wechat:") ||
key.startsWith("wechat:") ||
channel.includes("weixin") ||
channel.includes("wechat") ||
surface.includes("weixin") ||
surface.includes("wechat")
) {
return "微信";
}
return "Main/内部会话";
}
function classifySessionTypeFromSessionKey(sessionKey: string | undefined): "Cron" | "Discord" | "Telegram" | "Main/内部会话" {
function classifySessionTypeFromSessionKey(
sessionKey: string | undefined,
): "Cron" | "Discord" | "Telegram" | "飞书" | "微信" | "Main/内部会话" {
const key = sessionKey?.trim().toLowerCase() ?? "";
if (!key) return "Main/内部会话";
if (key.includes(":cron:") || key.startsWith("cron:")) return "Cron";
if (key.includes(":discord:") || key.startsWith("discord:")) return "Discord";
if (key.includes(":telegram:") || key.startsWith("telegram:")) return "Telegram";
if (key.includes(":feishu:") || key.startsWith("feishu:")) return "飞书";
if (key.includes(":weixin:") || key.startsWith("weixin:") || key.includes(":wechat:") || key.startsWith("wechat:")) return "微信";
return "Main/内部会话";
}
+6 -1
View File
@@ -1850,6 +1850,11 @@ export function renderCollaborationHallClientScript(language: UiLanguage): strin
}
return payload;
};
const shouldRetryLocalToken = (response, payload) => {
if (response.status === 401) return true;
if (response.status !== 403) return false;
return /invalid local token/i.test(extractErrorMessage(payload));
};
const callMutationJson = async (url, init) => {
const requestOnce = async (token) => {
const headers = {
@@ -1874,7 +1879,7 @@ export function renderCollaborationHallClientScript(language: UiLanguage): strin
let token = ensureToken(textTokenPrompt);
if (!token) throw new Error(textNeedToken);
let { response, payload } = await requestOnce(token);
if (response.status === 401) {
if (shouldRetryLocalToken(response, payload)) {
clearToken();
token = requestToken(textTokenRetryPrompt);
if (!token) throw new Error(textNeedToken);
+16 -3
View File
@@ -7476,7 +7476,7 @@ async function renderHtml(
</section>
<section class="card">
<h2>${escapeHtml(t("AI usage mix (all sessions)", "AI 用量构成(全部会话)"))}</h2>
<div class="meta">${escapeHtml(t("Timed jobs, Discord, Telegram, internal sessions", "定时任务、Discord、Telegram、内部会话"))}</div>
<div class="meta">${escapeHtml(t("Timed jobs, Discord, Telegram, Feishu, WeChat, internal sessions", "定时任务、Discord、Telegram、飞书、微信、内部会话"))}</div>
${usageSessionTypeShareHtml}
</section>
<section class="card">
@@ -11173,7 +11173,7 @@ async function renderHtml(
.staff-brief-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collaboration-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 1320px) {
@media (max-width: 980px) {
body:not(.section-hall-chat) .app-shell {
grid-template-columns: 1fr 284px;
grid-template-rows: auto minmax(0, 1fr);
@@ -15998,10 +15998,23 @@ function renderAvatarEditorScript(language: UiLanguage = "zh", importMutationEna
[tokenHeader]: token,
},
});
const shouldRetryLocalToken = async (response) => {
if (response.status === 401) return true;
if (response.status !== 403) return false;
try {
const payload = await response.clone().json();
const message = payload && payload.error && typeof payload.error === 'object' && payload.error.message
? String(payload.error.message)
: String(payload?.error || payload?.message || '');
return /invalid local token/i.test(message);
} catch {
return false;
}
};
let token = ensureToken(L.tokenPrompt);
if (!token) return null;
let response = await send(token);
if (response.status !== 401) return response;
if (!(await shouldRetryLocalToken(response))) return response;
clearToken();
token = requestToken(L.tokenRetryPrompt || L.tokenPrompt);
if (!token) return response;
+6 -1
View File
@@ -438,6 +438,11 @@ export function renderTaskRoomClientScript(language: UiLanguage): string {
};
const mutateRoom = async (url, body) => {
const shouldRetryLocalToken = (response, data) => {
if (response.status === 401) return true;
if (response.status !== 403) return false;
return /invalid local token/i.test(extractErrorMessage(data));
};
const requestOnce = async (token) => {
const headers = { 'Content-Type': 'application/json' };
if (token) headers[tokenHeader] = token;
@@ -457,7 +462,7 @@ export function renderTaskRoomClientScript(language: UiLanguage): string {
let token = ensureToken(labels.tokenPrompt);
if (!token) throw new Error(labels.needToken);
let { response, data } = await requestOnce(token);
if (response.status === 401) {
if (shouldRetryLocalToken(response, data)) {
clearToken();
token = requestToken(labels.tokenRetryPrompt || labels.tokenPrompt || labels.needToken);
if (!token) throw new Error(labels.needToken);
+5 -2
View File
@@ -55,7 +55,7 @@ test("collaboration hall renders a three-pane hall-first shell", () => {
assert(script.includes("syncSelectedTaskRefs"));
assert(script.includes("taskCardId: selectedTaskCardId"));
assert(script.includes("params.set('taskCardId', selectedTaskCardId)"));
assert(!script.includes("document.body?.dataset?.tokenRequired"));
assert(script.includes("document.body?.dataset?.tokenRequired"));
assert(script.includes("window.__openclawHallHandleComposerKeydown"));
assert(script.includes("window.__openclawHallHandleComposerKeyup"));
assert(script.includes("window.__openclawHallInsertMention"));
@@ -82,6 +82,9 @@ test("collaboration hall renders a three-pane hall-first shell", () => {
assert(script.includes("draft.persistedMessageId = event.messageId || '';"));
assert(script.includes("contextToggles.forEach"));
assert(script.includes("event.key === 'Escape'"));
assert(script.includes("const shouldRetryLocalToken = (response, payload) => {"));
assert(script.includes("response.status !== 403"));
assert(script.includes("/invalid local token/i.test(extractErrorMessage(payload))"));
});
test("hall chat page source wires the hall workbench into its own section", async () => {
@@ -90,7 +93,7 @@ test("hall chat page source wires the hall workbench into its own section", asyn
assert(source.includes("collaborationHallWorkbench"));
assert(source.includes("renderCollaborationHall({"));
assert(source.includes("renderCollaborationHallClientScript(options.language)"));
assert(source.includes("const hallChatSection = `"));
assert(source.includes('const hallChatSection = needsHallChat ? `'));
assert(source.includes("${collaborationHallWorkbench}"));
assert(source.includes('if (options.section === "hall-chat") sectionBody = hallChatSection;'));
});
+4 -1
View File
@@ -20,11 +20,14 @@ test("task room workbench renders the three-pane collaboration UI shell", () =>
assert(script.includes("new EventSource('/api/rooms/"));
assert(script.includes("draft_start"));
assert(script.includes("draft_delta"));
assert(script.includes("const shouldRetryLocalToken = (response, data) => {"));
assert(script.includes("response.status !== 403"));
assert(script.includes("/invalid local token/i.test(extractErrorMessage(data))"));
});
test("collaboration page source keeps the legacy collaboration board and linked task-room threads", async () => {
const source = await readFile("src/ui/server.ts", "utf8");
assert(source.includes("const collaborationSection = `"));
assert(source.includes('const collaborationSection = activeSection === "collaboration" ? `'));
assert(source.includes("Collaboration threads"));
assert(source.includes("${collaborationThreadHtml}"));
assert(source.includes("taskRoomWorkbench"));
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("hall smoke bootstrap installs playwright package and chromium when missing", async () => {
const [helperSource, hallSmokeSource] = await Promise.all([
readFile("scripts/ensure-playwright.js", "utf8"),
readFile("scripts/hall-release-smoke.ts", "utf8"),
]);
assert(helperSource.includes('npmCommand()'));
assert(helperSource.includes('["install", "--no-save", "playwright"]'));
assert(helperSource.includes('[cliPath, "install", "chromium"]'));
assert(helperSource.includes('playwright.chromium.launch({ headless: true })'));
assert(hallSmokeSource.includes('const helperModule = await import("./ensure-playwright.js");'));
assert(hallSmokeSource.includes('helperModule.ensurePlaywrightChromium ??'));
assert(hallSmokeSource.includes("await ensureBrowserAutomation();"));
});
+5 -1
View File
@@ -410,6 +410,7 @@ test("dashboard desktop layout keeps sidebars fixed while main content scrolls l
assert(source.includes("overflow-y: auto;"));
assert(source.includes("scrollbar-gutter: stable;"));
assert(source.includes(".sidebar::-webkit-scrollbar,"));
assert(source.includes("@media (max-width: 980px) {"));
assert(source.includes("body:not(.section-hall-chat) .app-shell {"));
assert(source.includes("grid-template-rows: auto minmax(0, 1fr);"));
assert(source.includes("body:not(.section-hall-chat) .panel {"));
@@ -470,7 +471,7 @@ test("usage dashboard includes token type share and cron token share sections",
assert(source.includes("usage_view"));
assert(source.includes("今天"));
assert(source.includes("累计"));
assert(source.includes("定时任务、Discord、Telegram、内部会话"));
assert(source.includes("定时任务、Discord、Telegram、飞书、微信、内部会话"));
assert(source.includes("renderTokenShareRows("));
assert(source.includes("usageCost.breakdownToday"));
assert(source.includes("selectedUsageBreakdown.bySessionType"));
@@ -918,4 +919,7 @@ test("ui sources never inject LOCAL_API_TOKEN into rendered HTML", async () => {
assert(!serverSource.includes("dataset?.localTokenValue"));
assert(!hallSource.includes("dataset?.localTokenValue"));
assert(!roomSource.includes("dataset?.localTokenValue"));
assert(serverSource.includes("return /invalid local token/i.test(message);"));
assert(hallSource.includes("/invalid local token/i.test(extractErrorMessage(payload))"));
assert(roomSource.includes("/invalid local token/i.test(extractErrorMessage(data))"));
});
+16
View File
@@ -310,6 +310,20 @@ test("usage-cost snapshot reports session-type share and cron-job share from ded
agentId: "main",
totalTokens: 25,
},
{
sessionKey: "agent:otter:feishu:thread:abc",
sessionId: "sid-5",
agentId: "otter",
totalTokens: 40,
channel: "feishu",
},
{
sessionKey: "agent:monkey:wechat:thread:xyz",
sessionId: "sid-6",
agentId: "monkey",
totalTokens: 60,
channel: "wechat",
},
],
events: [],
},
@@ -321,6 +335,8 @@ test("usage-cost snapshot reports session-type share and cron-job share from ded
assert.equal(byType.find((item) => item.label === "Cron")?.tokens, 1000);
assert.equal(byType.find((item) => item.label === "Discord")?.tokens, 100);
assert.equal(byType.find((item) => item.label === "Telegram")?.tokens, 50);
assert.equal(byType.find((item) => item.label === "飞书")?.tokens, 40);
assert.equal(byType.find((item) => item.label === "微信")?.tokens, 60);
assert.equal(byType.find((item) => item.label === "Main/内部会话")?.tokens, 25);
const cronTop = usage.breakdown.byCronJob[0];