mirror of
https://github.com/TianyiDataScience/openclaw-control-center.git
synced 2026-08-14 00:47:58 +00:00
fix: polish hall auth, layout, smoke, and usage channels
Fixes #74 Fixes #71 Fixes #73 Refs #70
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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/内部会话";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;'));
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();"));
|
||||
});
|
||||
@@ -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))"));
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user