feat: add CLI Apps settings MVP

This commit is contained in:
Xubin Ren
2026-05-23 00:33:31 +08:00
parent a5a956d9af
commit e2d00ffc8f
44 changed files with 4338 additions and 77 deletions
+19
View File
@@ -1,5 +1,6 @@
import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
@@ -106,6 +107,24 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
): Promise<CliAppsPayload> {
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
}
export async function runCliAppAction(
token: string,
action: "install" | "update" | "uninstall" | "test",
name: string,
base: string = "",
): Promise<CliAppsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
export async function listSlashCommands(
token: string,
base: string = "",
+3 -1
View File
@@ -2,6 +2,7 @@ import type {
ConnectionStatus,
InboundEvent,
Outbound,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@@ -304,7 +305,7 @@ export class NanobotClient {
chatId: string,
content: string,
media?: OutboundMedia[],
options?: { imageGeneration?: OutboundImageGeneration },
options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
): void {
this.knownChats.add(chatId);
const frame: Outbound = {
@@ -313,6 +314,7 @@ export class NanobotClient {
content,
...(media && media.length > 0 ? { media } : {}),
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
webui: true,
};
this.queueSend(frame);
+51 -4
View File
@@ -1,3 +1,5 @@
import type { ToolProgressEvent } from "@/lib/types";
/** Drop duplicate tool_call objects (same id or identical formatted trace). */
export function dedupeToolCallsForUi(calls: unknown): unknown[] {
if (!Array.isArray(calls) || calls.length === 0) return [];
@@ -40,15 +42,60 @@ export function formatToolCallTrace(call: unknown): string | null {
}
const VALID_PHASES = new Set(["start", "end", "error"]);
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
export function toolTraceLinesFromEvents(events: unknown): string[] {
export function normalizeToolProgressEvents(events: unknown): ToolProgressEvent[] {
if (!Array.isArray(events)) return [];
const seen = new Set<string>();
const lines: string[] = [];
const out: ToolProgressEvent[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const phase = (event as { phase?: unknown }).phase;
const record = event as ToolProgressEvent;
const phase = record.phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const name = typeof record.name === "string" ? record.name : "";
const functionName =
typeof (record as { function?: { name?: unknown } }).function?.name === "string"
? String((record as { function?: { name?: unknown } }).function?.name)
: "";
if (!name && !functionName) continue;
out.push(record);
}
return out;
}
function toolEventKey(event: ToolProgressEvent): string {
if (event.call_id) return `call:${event.call_id}`;
return formatToolCallTrace(event) ?? JSON.stringify(event);
}
export function mergeToolProgressEvents(
previous: ToolProgressEvent[] | undefined,
incoming: ToolProgressEvent[],
): ToolProgressEvent[] {
if (!previous?.length) return incoming;
if (!incoming.length) return previous;
const next = [...previous];
const indexByKey = new Map(next.map((event, index) => [toolEventKey(event), index]));
for (const event of incoming) {
const key = toolEventKey(event);
const existingIndex = indexByKey.get(key);
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(event);
continue;
}
const existing = next[existingIndex];
const incomingRank = PHASE_RANK[String(event.phase)] ?? 0;
const existingRank = PHASE_RANK[String(existing.phase)] ?? 0;
next[existingIndex] = incomingRank >= existingRank ? { ...existing, ...event } : existing;
}
return next;
}
export function toolTraceLinesFromEvents(events: unknown): string[] {
const seen = new Set<string>();
const lines: string[] = [];
for (const event of normalizeToolProgressEvents(events)) {
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;
+52
View File
@@ -40,6 +40,9 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
/** Structured tool events behind trace rows. Kept so activity cards can
* distinguish running, completed, and failed tool phases. */
toolEvents?: ToolProgressEvent[];
/** Activity rows: explicit file edits emitted by edit tools. */
fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */
@@ -48,6 +51,8 @@ export interface UIMessage {
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
media?: UIMediaAttachment[];
/** App-specific CLI adapters explicitly attached to this user turn. */
cliApps?: UICliAppAttachment[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -59,6 +64,15 @@ export interface UIMessage {
latencyMs?: number;
}
export interface UICliAppAttachment {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@@ -252,6 +266,34 @@ export interface SettingsPayload {
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface CliAppInfo {
name: string;
display_name: string;
category: string;
description: string;
requires: string;
source: string;
entry_point: string;
install_supported: boolean;
installed: boolean;
available: boolean;
status: "installed" | "missing" | "available" | "unsupported" | "not_installed" | string;
logo_url?: string | null;
brand_color?: string | null;
skill_installed: boolean;
}
export interface CliAppsPayload {
apps: CliAppInfo[];
installed_count: number;
catalog_updated_at?: string | null;
last_action?: {
ok: boolean;
message: string;
output?: string | null;
};
}
export interface SettingsUpdate {
model?: string;
provider?: string;
@@ -394,6 +436,15 @@ export interface OutboundImageGeneration {
aspect_ratio?: string | null;
}
export interface OutboundCliAppMention {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
export interface WebuiThreadPersistedPayload {
schemaVersion: number;
@@ -411,6 +462,7 @@ export type Outbound =
content: string;
media?: OutboundMedia[];
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;