mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(ui): dedupe state, views, and usage helpers
This commit is contained in:
@@ -149,24 +149,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
}
|
||||
|
||||
export function setTab(host: SettingsHost, next: Tab) {
|
||||
if (host.tab !== next) {
|
||||
host.tab = next;
|
||||
}
|
||||
if (next === "chat") {
|
||||
host.chatHasAutoScrolled = false;
|
||||
}
|
||||
if (next === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
} else {
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
}
|
||||
if (next === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
} else {
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
}
|
||||
void refreshActiveTab(host);
|
||||
syncUrlWithTab(host, next, false);
|
||||
applyTabSelection(host, next, { refreshPolicy: "always", syncUrl: true });
|
||||
}
|
||||
|
||||
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
|
||||
@@ -349,6 +332,14 @@ export function onPopState(host: SettingsHost) {
|
||||
}
|
||||
|
||||
export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
applyTabSelection(host, next, { refreshPolicy: "connected" });
|
||||
}
|
||||
|
||||
function applyTabSelection(
|
||||
host: SettingsHost,
|
||||
next: Tab,
|
||||
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
|
||||
) {
|
||||
if (host.tab !== next) {
|
||||
host.tab = next;
|
||||
}
|
||||
@@ -365,9 +356,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
} else {
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
}
|
||||
if (host.connected) {
|
||||
|
||||
if (options.refreshPolicy === "always" || host.connected) {
|
||||
void refreshActiveTab(host);
|
||||
}
|
||||
|
||||
if (options.syncUrl) {
|
||||
syncUrlWithTab(host, next, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
|
||||
import type {
|
||||
CronFieldErrors,
|
||||
CronJobsLastStatusFilter,
|
||||
CronJobsScheduleKindFilter,
|
||||
} from "./controllers/cron.ts";
|
||||
import type { CronModelSuggestionsState, CronState } from "./controllers/cron.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
@@ -21,16 +17,6 @@ import type {
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronJobsEnabledFilter,
|
||||
CronJobsSortBy,
|
||||
CronDeliveryStatus,
|
||||
CronRunScope,
|
||||
CronSortDir,
|
||||
CronRunsStatusValue,
|
||||
CronRunsStatusFilter,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
@@ -44,7 +30,7 @@ import type {
|
||||
ToolsCatalogResult,
|
||||
StatusSummary,
|
||||
} from "./types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
import type { SessionLogEntry } from "./views/usage.ts";
|
||||
|
||||
@@ -203,130 +189,133 @@ export type AppViewState = {
|
||||
usageLogFilterTools: string[];
|
||||
usageLogFilterHasTools: boolean;
|
||||
usageLogFilterQuery: string;
|
||||
cronLoading: boolean;
|
||||
cronJobsLoadingMore: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronJobsTotal: number;
|
||||
cronJobsHasMore: boolean;
|
||||
cronJobsNextOffset: number | null;
|
||||
cronJobsLimit: number;
|
||||
cronJobsQuery: string;
|
||||
cronJobsEnabledFilter: CronJobsEnabledFilter;
|
||||
cronJobsScheduleKindFilter: CronJobsScheduleKindFilter;
|
||||
cronJobsLastStatusFilter: CronJobsLastStatusFilter;
|
||||
cronJobsSortBy: CronJobsSortBy;
|
||||
cronJobsSortDir: CronSortDir;
|
||||
cronStatus: CronStatus | null;
|
||||
cronError: string | null;
|
||||
cronForm: CronFormState;
|
||||
cronFieldErrors: CronFieldErrors;
|
||||
cronEditingJobId: string | null;
|
||||
cronRunsJobId: string | null;
|
||||
cronRunsLoadingMore: boolean;
|
||||
cronRuns: CronRunLogEntry[];
|
||||
cronRunsTotal: number;
|
||||
cronRunsHasMore: boolean;
|
||||
cronRunsNextOffset: number | null;
|
||||
cronRunsLimit: number;
|
||||
cronRunsScope: CronRunScope;
|
||||
cronRunsStatuses: CronRunsStatusValue[];
|
||||
cronRunsDeliveryStatuses: CronDeliveryStatus[];
|
||||
cronRunsStatusFilter: CronRunsStatusFilter;
|
||||
cronRunsQuery: string;
|
||||
cronRunsSortDir: CronSortDir;
|
||||
cronModelSuggestions: string[];
|
||||
cronBusy: boolean;
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsFilter: string;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsFilterText: string;
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
logsCursor: number | null;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
updateAvailable: import("./types.js").UpdateAvailable | null;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
|
||||
handleNostrProfileCancel: () => void;
|
||||
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
|
||||
handleNostrProfileSave: () => Promise<void>;
|
||||
handleNostrProfileImport: () => Promise<void>;
|
||||
handleNostrProfileToggleAdvanced: () => void;
|
||||
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
|
||||
handleGatewayUrlConfirm: () => void;
|
||||
handleGatewayUrlCancel: () => void;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
handleConfigFormUpdate: (path: string, value: unknown) => void;
|
||||
handleConfigFormModeChange: (mode: "form" | "raw") => void;
|
||||
handleConfigRawChange: (raw: string) => void;
|
||||
handleInstallSkill: (key: string) => Promise<void>;
|
||||
handleUpdateSkill: (key: string) => Promise<void>;
|
||||
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
|
||||
handleUpdateSkillEdit: (key: string, value: string) => void;
|
||||
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
|
||||
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
|
||||
handleCronRun: (jobId: string) => Promise<void>;
|
||||
handleCronRemove: (jobId: string) => Promise<void>;
|
||||
handleCronAdd: () => Promise<void>;
|
||||
handleCronRunsLoad: (jobId: string) => Promise<void>;
|
||||
handleCronFormUpdate: (path: string, value: unknown) => void;
|
||||
handleSessionsLoad: () => Promise<void>;
|
||||
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
|
||||
handleLoadNodes: () => Promise<void>;
|
||||
handleLoadPresence: () => Promise<void>;
|
||||
handleLoadSkills: () => Promise<void>;
|
||||
handleLoadDebug: () => Promise<void>;
|
||||
handleLoadLogs: () => Promise<void>;
|
||||
handleDebugCall: () => Promise<void>;
|
||||
handleRunUpdate: () => Promise<void>;
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||
handleAbortChat: () => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
};
|
||||
} & Pick<
|
||||
CronState,
|
||||
| "cronLoading"
|
||||
| "cronJobsLoadingMore"
|
||||
| "cronJobs"
|
||||
| "cronJobsTotal"
|
||||
| "cronJobsHasMore"
|
||||
| "cronJobsNextOffset"
|
||||
| "cronJobsLimit"
|
||||
| "cronJobsQuery"
|
||||
| "cronJobsEnabledFilter"
|
||||
| "cronJobsScheduleKindFilter"
|
||||
| "cronJobsLastStatusFilter"
|
||||
| "cronJobsSortBy"
|
||||
| "cronJobsSortDir"
|
||||
| "cronStatus"
|
||||
| "cronError"
|
||||
| "cronForm"
|
||||
| "cronFieldErrors"
|
||||
| "cronEditingJobId"
|
||||
| "cronRunsJobId"
|
||||
| "cronRunsLoadingMore"
|
||||
| "cronRuns"
|
||||
| "cronRunsTotal"
|
||||
| "cronRunsHasMore"
|
||||
| "cronRunsNextOffset"
|
||||
| "cronRunsLimit"
|
||||
| "cronRunsScope"
|
||||
| "cronRunsStatuses"
|
||||
| "cronRunsDeliveryStatuses"
|
||||
| "cronRunsStatusFilter"
|
||||
| "cronRunsQuery"
|
||||
| "cronRunsSortDir"
|
||||
| "cronBusy"
|
||||
> &
|
||||
Pick<CronModelSuggestionsState, "cronModelSuggestions"> & {
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsFilter: string;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsFilterText: string;
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
logsCursor: number | null;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
updateAvailable: import("./types.js").UpdateAvailable | null;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
|
||||
handleNostrProfileCancel: () => void;
|
||||
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
|
||||
handleNostrProfileSave: () => Promise<void>;
|
||||
handleNostrProfileImport: () => Promise<void>;
|
||||
handleNostrProfileToggleAdvanced: () => void;
|
||||
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
|
||||
handleGatewayUrlConfirm: () => void;
|
||||
handleGatewayUrlCancel: () => void;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
handleConfigFormUpdate: (path: string, value: unknown) => void;
|
||||
handleConfigFormModeChange: (mode: "form" | "raw") => void;
|
||||
handleConfigRawChange: (raw: string) => void;
|
||||
handleInstallSkill: (key: string) => Promise<void>;
|
||||
handleUpdateSkill: (key: string) => Promise<void>;
|
||||
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
|
||||
handleUpdateSkillEdit: (key: string, value: string) => void;
|
||||
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
|
||||
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
|
||||
handleCronRun: (jobId: string) => Promise<void>;
|
||||
handleCronRemove: (jobId: string) => Promise<void>;
|
||||
handleCronAdd: () => Promise<void>;
|
||||
handleCronRunsLoad: (jobId: string) => Promise<void>;
|
||||
handleCronFormUpdate: (path: string, value: unknown) => void;
|
||||
handleSessionsLoad: () => Promise<void>;
|
||||
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
|
||||
handleLoadNodes: () => Promise<void>;
|
||||
handleLoadPresence: () => Promise<void>;
|
||||
handleLoadSkills: () => Promise<void>;
|
||||
handleLoadDebug: () => Promise<void>;
|
||||
handleLoadLogs: () => Promise<void>;
|
||||
handleDebugCall: () => Promise<void>;
|
||||
handleRunUpdate: () => Promise<void>;
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||
handleAbortChat: () => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
};
|
||||
|
||||
@@ -5,51 +5,24 @@ import { stripThinkingTags } from "../format.ts";
|
||||
const textCache = new WeakMap<object, string | null>();
|
||||
const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
function processMessageText(text: string, role: string): string {
|
||||
const shouldStripInboundMetadata = role.toLowerCase() === "user";
|
||||
if (role === "assistant") {
|
||||
return stripThinkingTags(text);
|
||||
}
|
||||
return shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(text))
|
||||
: stripEnvelope(text);
|
||||
}
|
||||
|
||||
export function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
const shouldStripInboundMetadata = role.toLowerCase() === "user";
|
||||
const content = m.content;
|
||||
if (typeof content === "string") {
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(content)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(content))
|
||||
: stripEnvelope(content);
|
||||
return processed;
|
||||
const raw = extractRawText(message);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
if (parts.length > 0) {
|
||||
const joined = parts.join("\n");
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(joined)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(joined))
|
||||
: stripEnvelope(joined);
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
if (typeof m.text === "string") {
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(m.text)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(m.text))
|
||||
: stripEnvelope(m.text);
|
||||
return processed;
|
||||
}
|
||||
return null;
|
||||
return processMessageText(raw, role);
|
||||
}
|
||||
|
||||
export function extractTextCached(message: unknown): string | null {
|
||||
|
||||
@@ -37,6 +37,15 @@ function createState(): ConfigState {
|
||||
};
|
||||
}
|
||||
|
||||
function createRequestWithConfigGet() {
|
||||
return vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
@@ -160,12 +169,7 @@ describe("applyConfig", () => {
|
||||
});
|
||||
|
||||
it("coerces schema-typed values before config.apply in form mode", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const request = createRequestWithConfigGet();
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
@@ -209,12 +213,7 @@ describe("applyConfig", () => {
|
||||
|
||||
describe("saveConfig", () => {
|
||||
it("coerces schema-typed values before config.set in form mode", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const request = createRequestWithConfigGet();
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
@@ -250,12 +249,7 @@ describe("saveConfig", () => {
|
||||
});
|
||||
|
||||
it("skips coercion when schema is not an object", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const request = createRequestWithConfigGet();
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
|
||||
@@ -89,17 +89,29 @@ function makeConfigWithProvider(): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstXaiModel(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const model = payload.models as Record<string, unknown>;
|
||||
const providers = model.providers as Record<string, unknown>;
|
||||
const xai = providers.xai as Record<string, unknown>;
|
||||
const models = xai.models as Array<Record<string, unknown>>;
|
||||
return models[0] ?? {};
|
||||
}
|
||||
|
||||
function expectNumericModelCore(model: Record<string, unknown>) {
|
||||
expect(typeof model.maxTokens).toBe("number");
|
||||
expect(model.maxTokens).toBe(8192);
|
||||
expect(typeof model.contextWindow).toBe("number");
|
||||
expect(model.contextWindow).toBe(131072);
|
||||
}
|
||||
|
||||
describe("form-utils preserves numeric types", () => {
|
||||
it("serializeConfigForm preserves numbers in JSON output", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const raw = serializeConfigForm(form);
|
||||
const parsed = JSON.parse(raw);
|
||||
const model = parsed.models.providers.xai.models[0];
|
||||
const model = parsed.models.providers.xai.models[0] as Record<string, unknown>;
|
||||
|
||||
expect(typeof model.maxTokens).toBe("number");
|
||||
expect(model.maxTokens).toBe(8192);
|
||||
expect(typeof model.contextWindow).toBe("number");
|
||||
expect(model.contextWindow).toBe(131072);
|
||||
expectNumericModelCore(model);
|
||||
expect(typeof model.cost.input).toBe("number");
|
||||
expect(model.cost.input).toBe(0.5);
|
||||
});
|
||||
@@ -108,16 +120,9 @@ describe("form-utils preserves numeric types", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const cloned = cloneConfigObject(form);
|
||||
setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
|
||||
const first = getFirstXaiModel(cloned);
|
||||
|
||||
const model = cloned.models as Record<string, unknown>;
|
||||
const providers = model.providers as Record<string, unknown>;
|
||||
const xai = providers.xai as Record<string, unknown>;
|
||||
const models = xai.models as Array<Record<string, unknown>>;
|
||||
const first = models[0];
|
||||
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
expect(typeof first.contextWindow).toBe("number");
|
||||
expectNumericModelCore(first);
|
||||
expect(typeof first.cost).toBe("object");
|
||||
expect(typeof (first.cost as Record<string, unknown>).input).toBe("number");
|
||||
});
|
||||
@@ -145,16 +150,9 @@ describe("coerceFormValues", () => {
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
const first = getFirstXaiModel(coerced);
|
||||
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
expect(typeof first.contextWindow).toBe("number");
|
||||
expect(first.contextWindow).toBe(131072);
|
||||
expectNumericModelCore(first);
|
||||
expect(typeof first.cost).toBe("object");
|
||||
const cost = first.cost as Record<string, number>;
|
||||
expect(typeof cost.input).toBe("number");
|
||||
@@ -170,12 +168,7 @@ describe("coerceFormValues", () => {
|
||||
it("preserves already-correct numeric values", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
|
||||
const first = getFirstXaiModel(coerced);
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
});
|
||||
@@ -199,11 +192,7 @@ describe("coerceFormValues", () => {
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
const first = getFirstXaiModel(coerced);
|
||||
|
||||
expect(first.maxTokens).toBe("not-a-number");
|
||||
});
|
||||
@@ -227,11 +216,8 @@ describe("coerceFormValues", () => {
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
expect(model[0].reasoning).toBe(true);
|
||||
const first = getFirstXaiModel(coerced);
|
||||
expect(first.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("handles empty string for number fields as undefined", () => {
|
||||
@@ -253,11 +239,8 @@ describe("coerceFormValues", () => {
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
expect(model[0].maxTokens).toBeUndefined();
|
||||
const first = getFirstXaiModel(coerced);
|
||||
expect(first.maxTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through null and undefined values untouched", () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
clearDeviceAuthTokenFromStore,
|
||||
type DeviceAuthEntry,
|
||||
type DeviceAuthStore,
|
||||
normalizeDeviceAuthRole,
|
||||
normalizeDeviceAuthScopes,
|
||||
} from "../../../src/shared/device-auth.js";
|
||||
loadDeviceAuthTokenFromStore,
|
||||
storeDeviceAuthTokenInStore,
|
||||
} from "../../../src/shared/device-auth-store.js";
|
||||
import type { DeviceAuthStore } from "../../../src/shared/device-auth.js";
|
||||
|
||||
const STORAGE_KEY = "openclaw.device.auth.v1";
|
||||
|
||||
@@ -41,16 +42,11 @@ export function loadDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
}): DeviceAuthEntry | null {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return null;
|
||||
}
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const entry = store.tokens[role];
|
||||
if (!entry || typeof entry.token !== "string") {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
return loadDeviceAuthTokenFromStore({
|
||||
adapter: { readStore, writeStore },
|
||||
deviceId: params.deviceId,
|
||||
role: params.role,
|
||||
});
|
||||
}
|
||||
|
||||
export function storeDeviceAuthToken(params: {
|
||||
@@ -59,37 +55,19 @@ export function storeDeviceAuthToken(params: {
|
||||
token: string;
|
||||
scopes?: string[];
|
||||
}): DeviceAuthEntry {
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const next: DeviceAuthStore = {
|
||||
version: 1,
|
||||
return storeDeviceAuthTokenInStore({
|
||||
adapter: { readStore, writeStore },
|
||||
deviceId: params.deviceId,
|
||||
tokens: {},
|
||||
};
|
||||
const existing = readStore();
|
||||
if (existing && existing.deviceId === params.deviceId) {
|
||||
next.tokens = { ...existing.tokens };
|
||||
}
|
||||
const entry: DeviceAuthEntry = {
|
||||
role: params.role,
|
||||
token: params.token,
|
||||
role,
|
||||
scopes: normalizeDeviceAuthScopes(params.scopes),
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
next.tokens[role] = entry;
|
||||
writeStore(next);
|
||||
return entry;
|
||||
scopes: params.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return;
|
||||
}
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
if (!store.tokens[role]) {
|
||||
return;
|
||||
}
|
||||
const next = { ...store, tokens: { ...store.tokens } };
|
||||
delete next.tokens[role];
|
||||
writeStore(next);
|
||||
clearDeviceAuthTokenFromStore({
|
||||
adapter: { readStore, writeStore },
|
||||
deviceId: params.deviceId,
|
||||
role: params.role,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
defaultTitle,
|
||||
formatToolDetailText,
|
||||
normalizeToolName,
|
||||
normalizeVerb,
|
||||
resolveActionSpec,
|
||||
resolveDetailFromKeys,
|
||||
resolveExecDetail,
|
||||
resolveReadDetail,
|
||||
resolveWebFetchDetail,
|
||||
resolveWebSearchDetail,
|
||||
resolveWriteDetail,
|
||||
resolveActionArg,
|
||||
resolveToolVerbAndDetail,
|
||||
type ToolDisplaySpec as ToolDisplaySpecBase,
|
||||
} from "../../../src/agents/tool-display-common.js";
|
||||
import type { IconName } from "./icons.ts";
|
||||
@@ -69,50 +64,17 @@ export function resolveToolDisplay(params: {
|
||||
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName;
|
||||
const title = spec?.title ?? defaultTitle(name);
|
||||
const label = spec?.label ?? title;
|
||||
const actionRaw =
|
||||
params.args && typeof params.args === "object"
|
||||
? ((params.args as Record<string, unknown>).action as string | undefined)
|
||||
: undefined;
|
||||
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
|
||||
const actionSpec = resolveActionSpec(spec, action);
|
||||
const fallbackVerb =
|
||||
key === "web_search"
|
||||
? "search"
|
||||
: key === "web_fetch"
|
||||
? "fetch"
|
||||
: key.replace(/_/g, " ").replace(/\./g, " ");
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action ?? fallbackVerb);
|
||||
|
||||
let detail: string | undefined;
|
||||
if (key === "exec") {
|
||||
detail = resolveExecDetail(params.args);
|
||||
}
|
||||
if (!detail && key === "read") {
|
||||
detail = resolveReadDetail(params.args);
|
||||
}
|
||||
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
|
||||
detail = resolveWriteDetail(key, params.args);
|
||||
}
|
||||
|
||||
if (!detail && key === "web_search") {
|
||||
detail = resolveWebSearchDetail(params.args);
|
||||
}
|
||||
|
||||
if (!detail && key === "web_fetch") {
|
||||
detail = resolveWebFetchDetail(params.args);
|
||||
}
|
||||
|
||||
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
|
||||
if (!detail && detailKeys.length > 0) {
|
||||
detail = resolveDetailFromKeys(params.args, detailKeys, {
|
||||
mode: "first",
|
||||
coerce: { includeFalse: true, includeZero: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!detail && params.meta) {
|
||||
detail = params.meta;
|
||||
}
|
||||
const action = resolveActionArg(params.args);
|
||||
let { verb, detail } = resolveToolVerbAndDetail({
|
||||
toolKey: key,
|
||||
args: params.args,
|
||||
meta: params.meta,
|
||||
action,
|
||||
spec,
|
||||
fallbackDetailKeys: FALLBACK.detailKeys,
|
||||
detailMode: "first",
|
||||
detailCoerce: { includeFalse: true, includeZero: true },
|
||||
});
|
||||
|
||||
if (detail) {
|
||||
detail = shortenHomeInString(detail);
|
||||
@@ -129,18 +91,7 @@ export function resolveToolDisplay(params: {
|
||||
}
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
if (!display.detail) {
|
||||
return undefined;
|
||||
}
|
||||
if (display.detail.includes(" · ")) {
|
||||
const compact = display.detail
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join(", ");
|
||||
return compact ? `with ${compact}` : undefined;
|
||||
}
|
||||
return display.detail;
|
||||
return formatToolDetailText(display.detail, { prefixWithWith: true });
|
||||
}
|
||||
|
||||
export function formatToolSummary(display: ToolDisplay): string {
|
||||
|
||||
@@ -1,193 +1,8 @@
|
||||
export type SessionsUsageEntry = {
|
||||
key: string;
|
||||
label?: string;
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
origin?: {
|
||||
label?: string;
|
||||
provider?: string;
|
||||
surface?: string;
|
||||
chatType?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
modelOverride?: string;
|
||||
providerOverride?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
cacheReadCost?: number;
|
||||
cacheWriteCost?: number;
|
||||
missingCostEntries: number;
|
||||
firstActivity?: number;
|
||||
lastActivity?: number;
|
||||
durationMs?: number;
|
||||
activityDates?: string[];
|
||||
dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>;
|
||||
dailyMessageCounts?: Array<{
|
||||
date: string;
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
}>;
|
||||
dailyLatency?: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
}>;
|
||||
dailyModelUsage?: Array<{
|
||||
date: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
count: number;
|
||||
}>;
|
||||
messageCounts?: {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
};
|
||||
toolUsage?: {
|
||||
totalCalls: number;
|
||||
uniqueTools: number;
|
||||
tools: Array<{ name: string; count: number }>;
|
||||
};
|
||||
modelUsage?: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
latency?: {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
} | null;
|
||||
contextWeight?: {
|
||||
systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number };
|
||||
skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> };
|
||||
tools: {
|
||||
listChars: number;
|
||||
schemaChars: number;
|
||||
entries: Array<{ name: string; summaryChars: number; schemaChars: number }>;
|
||||
};
|
||||
injectedWorkspaceFiles: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncated: boolean;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
import type { SessionsUsageResult as SharedSessionsUsageResult } from "../../../src/shared/usage-types.js";
|
||||
|
||||
export type SessionsUsageTotals = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheWriteCost: number;
|
||||
missingCostEntries: number;
|
||||
};
|
||||
|
||||
export type SessionsUsageResult = {
|
||||
updatedAt: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sessions: SessionsUsageEntry[];
|
||||
totals: SessionsUsageTotals;
|
||||
aggregates: {
|
||||
messages: {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
};
|
||||
tools: {
|
||||
totalCalls: number;
|
||||
uniqueTools: number;
|
||||
tools: Array<{ name: string; count: number }>;
|
||||
};
|
||||
byModel: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
byProvider: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>;
|
||||
byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>;
|
||||
latency?: {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
dailyLatency?: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
}>;
|
||||
modelDaily?: Array<{
|
||||
date: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
count: number;
|
||||
}>;
|
||||
daily: Array<{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
export type SessionsUsageEntry = SharedSessionsUsageResult["sessions"][number];
|
||||
export type SessionsUsageTotals = SharedSessionsUsageResult["totals"];
|
||||
export type SessionsUsageResult = SharedSessionsUsageResult;
|
||||
|
||||
export type CostUsageDailyEntry = SessionsUsageTotals & { date: string };
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ describe("config view", () => {
|
||||
onSubsectionChange: vi.fn(),
|
||||
});
|
||||
|
||||
function findActionButtons(container: HTMLElement): {
|
||||
saveButton?: HTMLButtonElement;
|
||||
applyButton?: HTMLButtonElement;
|
||||
} {
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
return {
|
||||
saveButton: buttons.find((btn) => btn.textContent?.trim() === "Save"),
|
||||
applyButton: buttons.find((btn) => btn.textContent?.trim() === "Apply"),
|
||||
};
|
||||
}
|
||||
|
||||
it("allows save when form is unsafe", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
@@ -97,12 +108,7 @@ describe("config view", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Save",
|
||||
);
|
||||
const applyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Apply",
|
||||
);
|
||||
const { saveButton, applyButton } = findActionButtons(container);
|
||||
expect(saveButton).not.toBeUndefined();
|
||||
expect(applyButton).not.toBeUndefined();
|
||||
expect(saveButton?.disabled).toBe(true);
|
||||
@@ -121,12 +127,7 @@ describe("config view", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Save",
|
||||
);
|
||||
const applyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Apply",
|
||||
);
|
||||
const { saveButton, applyButton } = findActionButtons(container);
|
||||
expect(saveButton).not.toBeUndefined();
|
||||
expect(applyButton).not.toBeUndefined();
|
||||
expect(saveButton?.disabled).toBe(false);
|
||||
|
||||
@@ -4,6 +4,11 @@ import type {
|
||||
ExecApprovalsFile,
|
||||
} from "../controllers/exec-approvals.ts";
|
||||
import { clampText, formatRelativeTimestamp } from "../format.ts";
|
||||
import {
|
||||
resolveConfigAgents as resolveSharedConfigAgents,
|
||||
resolveNodeTargets,
|
||||
type NodeTargetOption,
|
||||
} from "./nodes-shared.ts";
|
||||
import type { NodesProps } from "./nodes.ts";
|
||||
|
||||
type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
@@ -22,10 +27,7 @@ type ExecApprovalsAgentOption = {
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
type ExecApprovalsTargetNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
type ExecApprovalsTargetNode = NodeTargetOption;
|
||||
|
||||
type ExecApprovalsState = {
|
||||
ready: boolean;
|
||||
@@ -91,23 +93,11 @@ function resolveExecApprovalsDefaults(
|
||||
}
|
||||
|
||||
function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprovalsAgentOption[] {
|
||||
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
|
||||
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
|
||||
const agents: ExecApprovalsAgentOption[] = [];
|
||||
list.forEach((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const name = typeof record.name === "string" ? record.name.trim() : undefined;
|
||||
const isDefault = record.default === true;
|
||||
agents.push({ id, name: name || undefined, isDefault });
|
||||
});
|
||||
return agents;
|
||||
return resolveSharedConfigAgents(config).map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
isDefault: entry.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveExecApprovalsAgents(
|
||||
@@ -623,29 +613,5 @@ function renderAllowlistEntry(
|
||||
function resolveExecApprovalsNodes(
|
||||
nodes: Array<Record<string, unknown>>,
|
||||
): ExecApprovalsTargetNode[] {
|
||||
const list: ExecApprovalsTargetNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
const supports = commands.some(
|
||||
(cmd) =>
|
||||
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
|
||||
);
|
||||
if (!supports) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
|
||||
if (!nodeId) {
|
||||
continue;
|
||||
}
|
||||
const displayName =
|
||||
typeof node.displayName === "string" && node.displayName.trim()
|
||||
? node.displayName.trim()
|
||||
: nodeId;
|
||||
list.push({
|
||||
id: nodeId,
|
||||
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return list;
|
||||
return resolveNodeTargets(nodes, ["system.execApprovals.get", "system.execApprovals.set"]);
|
||||
}
|
||||
|
||||
67
ui/src/ui/views/nodes-shared.ts
Normal file
67
ui/src/ui/views/nodes-shared.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type NodeTargetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ConfigAgentOption = {
|
||||
id: string;
|
||||
name?: string;
|
||||
isDefault: boolean;
|
||||
index: number;
|
||||
record: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function resolveConfigAgents(config: Record<string, unknown> | null): ConfigAgentOption[] {
|
||||
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
|
||||
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
|
||||
const agents: ConfigAgentOption[] = [];
|
||||
|
||||
list.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const name = typeof record.name === "string" ? record.name.trim() : undefined;
|
||||
const isDefault = record.default === true;
|
||||
agents.push({ id, name: name || undefined, isDefault, index, record });
|
||||
});
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function resolveNodeTargets(
|
||||
nodes: Array<Record<string, unknown>>,
|
||||
requiredCommands: string[],
|
||||
): NodeTargetOption[] {
|
||||
const required = new Set(requiredCommands);
|
||||
const list: NodeTargetOption[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
const supports = commands.some((cmd) => required.has(String(cmd)));
|
||||
if (!supports) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
|
||||
if (!nodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
typeof node.displayName === "string" && node.displayName.trim()
|
||||
? node.displayName.trim()
|
||||
: nodeId;
|
||||
list.push({
|
||||
id: nodeId,
|
||||
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
|
||||
});
|
||||
}
|
||||
|
||||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return list;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../controllers/exec-approvals.ts";
|
||||
import { formatRelativeTimestamp, formatList } from "../format.ts";
|
||||
import { renderExecApprovals, resolveExecApprovalsState } from "./nodes-exec-approvals.ts";
|
||||
import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./nodes-shared.ts";
|
||||
export type NodesProps = {
|
||||
loading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
@@ -223,10 +224,7 @@ type BindingAgent = {
|
||||
binding?: string | null;
|
||||
};
|
||||
|
||||
type BindingNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
type BindingNode = NodeTargetOption;
|
||||
|
||||
type BindingState = {
|
||||
ready: boolean;
|
||||
@@ -408,28 +406,7 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
||||
}
|
||||
|
||||
function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[] {
|
||||
const list: BindingNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
const supports = commands.some((cmd) => String(cmd) === "system.run");
|
||||
if (!supports) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
|
||||
if (!nodeId) {
|
||||
continue;
|
||||
}
|
||||
const displayName =
|
||||
typeof node.displayName === "string" && node.displayName.trim()
|
||||
? node.displayName.trim()
|
||||
: nodeId;
|
||||
list.push({
|
||||
id: nodeId,
|
||||
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return list;
|
||||
return resolveNodeTargets(nodes, ["system.run"]);
|
||||
}
|
||||
|
||||
function resolveAgentBindings(config: Record<string, unknown> | null): {
|
||||
@@ -452,34 +429,22 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
|
||||
typeof exec.node === "string" && exec.node.trim() ? exec.node.trim() : null;
|
||||
|
||||
const agentsNode = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
|
||||
if (list.length === 0) {
|
||||
if (!Array.isArray(agentsNode.list) || agentsNode.list.length === 0) {
|
||||
return { defaultBinding, agents: [fallbackAgent] };
|
||||
}
|
||||
|
||||
const agents: BindingAgent[] = [];
|
||||
list.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const name = typeof record.name === "string" ? record.name.trim() : undefined;
|
||||
const isDefault = record.default === true;
|
||||
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
|
||||
const agents = resolveConfigAgents(config).map((entry) => {
|
||||
const toolsEntry = (entry.record.tools ?? {}) as Record<string, unknown>;
|
||||
const execEntry = (toolsEntry.exec ?? {}) as Record<string, unknown>;
|
||||
const binding =
|
||||
typeof execEntry.node === "string" && execEntry.node.trim() ? execEntry.node.trim() : null;
|
||||
agents.push({
|
||||
id,
|
||||
name: name || undefined,
|
||||
index,
|
||||
isDefault,
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
index: entry.index,
|
||||
isDefault: entry.isDefault,
|
||||
binding,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
if (agents.length === 0) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { html } from "lit";
|
||||
import { buildUsageAggregateTail } from "../../../../src/shared/usage-aggregates.js";
|
||||
import {
|
||||
buildUsageAggregateTail,
|
||||
mergeUsageDailyLatency,
|
||||
mergeUsageLatency,
|
||||
} from "../../../../src/shared/usage-aggregates.js";
|
||||
import { UsageSessionEntry, UsageTotals, UsageAggregates } from "./usageTypes.ts";
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
@@ -413,16 +417,7 @@ const buildAggregatesFromSessions = (
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.latency) {
|
||||
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
|
||||
if (count > 0) {
|
||||
latencyTotals.count += count;
|
||||
latencyTotals.sum += avgMs * count;
|
||||
latencyTotals.min = Math.min(latencyTotals.min, minMs);
|
||||
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
|
||||
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
|
||||
}
|
||||
}
|
||||
mergeUsageLatency(latencyTotals, usage.latency);
|
||||
|
||||
if (session.agentId) {
|
||||
const totals = agentMap.get(session.agentId) ?? emptyUsageTotals();
|
||||
@@ -462,22 +457,7 @@ const buildAggregatesFromSessions = (
|
||||
daily.errors += day.errors;
|
||||
dailyMap.set(day.date, daily);
|
||||
}
|
||||
for (const day of usage.dailyLatency ?? []) {
|
||||
const existing = dailyLatencyMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Number.POSITIVE_INFINITY,
|
||||
max: 0,
|
||||
p95Max: 0,
|
||||
};
|
||||
existing.count += day.count;
|
||||
existing.sum += day.avgMs * day.count;
|
||||
existing.min = Math.min(existing.min, day.minMs);
|
||||
existing.max = Math.max(existing.max, day.maxMs);
|
||||
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
|
||||
dailyLatencyMap.set(day.date, existing);
|
||||
}
|
||||
mergeUsageDailyLatency(dailyLatencyMap, usage.dailyLatency);
|
||||
for (const day of usage.dailyModelUsage ?? []) {
|
||||
const key = `${day.date}::${day.provider ?? "unknown"}::${day.model ?? "unknown"}`;
|
||||
const existing = modelDailyMap.get(key) ?? {
|
||||
|
||||
Reference in New Issue
Block a user