Просмотр исходного кода

feat(voice): show readable tool lifecycle diagnostics

Kit Langton 3 недель назад
Родитель
Сommit
c103ba6056
2 измененных файлов с 84 добавлено и 16 удалено
  1. 5 2
      packages/voice/src/spike.ts
  2. 79 14
      packages/voice/src/ui.tsx

+ 5 - 2
packages/voice/src/spike.ts

@@ -444,11 +444,14 @@ let inflightTools = 0
 
 async function handleFunctionCall(item: RealtimeItem) {
   inflightTools += 1
+  const callID = item.call_id ?? crypto.randomUUID()
+  const input = JSON.parse(item.arguments ?? "{}") as Record<string, unknown>
+  ui.toolStart(callID, item.name ?? "unknown", input)
   const handler = item.name ? toolHandlers[item.name] : undefined
   const output = handler
-    ? await handler(JSON.parse(item.arguments ?? "{}")).catch((error) => ({ error: String(error) }))
+    ? await handler(input).catch((error) => ({ error: String(error) }))
     : { error: `unknown tool ${item.name}` }
-  ui.tool(item.name ?? "unknown", output)
+  ui.toolDone(callID, output)
   send({
     type: "conversation.item.create",
     item: { type: "function_call_output", call_id: item.call_id, output: JSON.stringify(output) },

+ 79 - 14
packages/voice/src/ui.tsx

@@ -21,7 +21,8 @@ export type VoiceUI = {
   userTranscript(itemID: string, text: string): void
   assistantDelta(text: string): void
   assistantDone(): void
-  tool(name: string, output: unknown): void
+  toolStart(callID: string, name: string, input: unknown): void
+  toolDone(callID: string, output: unknown): void
   setStatus(patch: VoiceStatus): void
   close(): void
 }
@@ -29,13 +30,37 @@ export type VoiceUI = {
 type Message =
   | { kind: "user"; itemID: string; text?: string }
   | { kind: "assistant"; text: string; streaming: boolean }
-  | { kind: "tool"; name: string; json: string }
+  | { kind: "tool"; callID: string; name: string; input: unknown; output?: unknown }
   | { kind: "meta"; text: string }
 
 const truncate = (text: string, max: number) => (text.length > max ? text.slice(0, max) + "…" : text)
 
-const compactJson = (output: unknown) =>
-  JSON.stringify(output, (_, value) => (typeof value === "string" ? truncate(value, 200) : value)) ?? "null"
+const displayJson = (value: unknown) =>
+  JSON.stringify(value, (_, item) => (typeof item === "string" ? truncate(item, 500) : item), 2) ?? "null"
+
+function toolSummary(name: string, output?: unknown) {
+  if (output === undefined) return "running"
+  if (Array.isArray(output)) return `${output.length} result${output.length === 1 ? "" : "s"}`
+  if (!output || typeof output !== "object") return String(output)
+  const value = output as Record<string, unknown>
+  if (name === "wait_for_reply")
+    return `${String(value["status"] ?? "done")} · ${typeof value["text"] === "string" ? `${value["text"].length} chars` : "no reply"}`
+  if (name === "prompt_session")
+    return `${value["admitted"] ? "admitted" : "rejected"} · ${String(value["sessionID"] ?? "unknown session")}`
+  if (name === "check_session") {
+    const tools = Array.isArray(value["runningTools"]) ? ` · ${value["runningTools"].join(", ")}` : ""
+    return `${String(value["status"] ?? "checked")}${tools}`
+  }
+  if (name === "create_session" || name === "select_session")
+    return `${name === "create_session" ? "created" : "selected"} · ${String(value["sessionID"] ?? "unknown session")}`
+  if (name === "interrupt_session") return value["interrupted"] ? "interrupted" : "not interrupted"
+  if (name === "set_voice") return `switching to ${String(value["voice"] ?? "unknown")}`
+  const fields = Object.entries(value)
+    .filter(([, item]) => ["string", "number", "boolean"].includes(typeof item))
+    .slice(0, 2)
+    .map(([key, item]) => `${key}: ${String(item)}`)
+  return fields.join(" · ") || "completed"
+}
 
 // ---------------------------------------------------------------------------
 // Console fallback (--text mode, non-TTY)
@@ -48,6 +73,7 @@ export function createConsoleUI(): VoiceUI {
   const green = (text: string) => (tty ? `\x1b[1;32m${text}\x1b[0m` : text)
 
   let streaming = false
+  const tools = new Map<string, string>()
   const line = (text: string) => {
     if (streaming) {
       process.stdout.write("\n")
@@ -71,7 +97,15 @@ export function createConsoleUI(): VoiceUI {
       if (streaming) process.stdout.write("\n")
       streaming = false
     },
-    tool: (name, output) => line(dim(`  [${name}] `) + Bun.inspect(JSON.parse(compactJson(output)), { colors: tty })),
+    toolStart: (callID, name) => {
+      tools.set(callID, name)
+      line(dim(`  ◌ ${name}  running`))
+    },
+    toolDone: (callID, output) => {
+      const name = tools.get(callID) ?? "tool"
+      tools.delete(callID)
+      line(dim(`  ✓ ${name}  ${toolSummary(name, output)}`))
+    },
     setStatus: () => {},
     close: () => {},
   }
@@ -117,7 +151,7 @@ function jsonTokens(json: string) {
 
 // `kind` never changes after creation, so branching once here is safe; the
 // property reads inside JSX stay reactive through the store proxy.
-function MessageRow(props: { message: Message }) {
+function MessageRow(props: { message: Message; details: boolean }) {
   const message = props.message
   if (message.kind === "user")
     return (
@@ -134,14 +168,26 @@ function MessageRow(props: { message: Message }) {
     )
   if (message.kind === "tool")
     return (
-      <text fg={theme.muted} paddingLeft={2}>
-        [{message.name}]{" "}
-        <For each={jsonTokens(message.json)}>{(token) => <span style={{ fg: token.color }}>{token.text}</span>}</For>
-      </text>
+      <box flexDirection="column" paddingLeft={2}>
+        <text fg={theme.muted}>
+          <span style={{ fg: message.output === undefined ? theme.number : theme.assistant }}>
+            {message.output === undefined ? "◌" : "✓"}
+          </span>{" "}
+          <span style={{ fg: theme.key }}>{message.name}</span>{"  "}
+          {toolSummary(message.name, message.output)}
+        </text>
+        {props.details ? (
+          <text fg={theme.muted} paddingLeft={2}>
+            <For each={jsonTokens(displayJson({ input: message.input, output: message.output }))}>
+              {(token) => <span style={{ fg: token.color }}>{token.text}</span>}
+            </For>
+          </text>
+        ) : null}
+      </box>
     )
   return (
     <text fg={theme.muted} paddingLeft={2}>
-      {message.text}
+      · {message.text}
     </text>
   )
 }
@@ -154,6 +200,7 @@ export async function createVoiceTUI(options: {
   const [state, setState] = createSignal({
     messages: [] as Message[],
     status: {} as VoiceStatus,
+    details: false,
   })
 
   const renderer = await createCliRenderer({
@@ -175,6 +222,11 @@ export async function createVoiceTUI(options: {
     useKeyboard((evt) => {
       if (evt.ctrl && evt.name === "c") return
       if (evt.name === "v") return options.onCycleVoice()
+      if (evt.name === "d") {
+        setState((current) => ({ ...current, details: !current.details }))
+        renderer.requestRender()
+        return
+      }
       options.onInterrupt()
     })
     const statusLine = () =>
@@ -184,7 +236,7 @@ export async function createVoiceTUI(options: {
         state().status.model,
         state().status.session ?? "no session",
         state().status.server,
-        "v: voice · any key interrupts · ctrl+c quits",
+        `v: voice · d: details ${state().details ? "on" : "off"} · any key interrupts · ctrl+c quits`,
       ]
         .filter(Boolean)
         .join("   ")
@@ -198,7 +250,9 @@ export async function createVoiceTUI(options: {
       >
         <scrollbox flexGrow={1} stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
           <box flexDirection="column" flexShrink={0}>
-            <For each={state().messages}>{(message) => <MessageRow message={message} />}</For>
+            <For each={state().messages}>
+              {(message) => <MessageRow message={message} details={state().details} />}
+            </For>
           </box>
         </scrollbox>
         <box height={1} marginTop={1}>
@@ -256,7 +310,18 @@ export async function createVoiceTUI(options: {
       }))
       redraw()
     },
-    tool: (name, output) => push({ kind: "tool", name, json: compactJson(output) }),
+    toolStart: (callID, name, input) => push({ kind: "tool", callID, name, input }),
+    toolDone: (callID, output) => {
+      const index = state().messages.findIndex((message) => message.kind === "tool" && message.callID === callID)
+      if (index === -1) return
+      setState((current) => ({
+        ...current,
+        messages: current.messages.map((message, i) =>
+          i === index && message.kind === "tool" ? { ...message, output } : message,
+        ),
+      }))
+      redraw()
+    },
     setStatus: (patch) => {
       setState((current) => ({ ...current, status: { ...current.status, ...patch } }))
       redraw()