feat(voice): show readable tool lifecycle diagnostics
This commit is contained in:
parent
38f03c9d8b
commit
c103ba6056
2 changed files with 84 additions and 16 deletions
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue