Assistant node, and the Control tab as one conversation (openpanel V5)

run_turn takes its tools, system prompt and tool caller; the chat passes the
MCP tools as before. The assistant node offers one tool per wired target,
reads its other inputs as context, streams its reply a sentence at a time,
warms the provider on `listening` and says so when the provider is not there.
Completions share the per-server HTTP client. wren replies when nothing reads
`unhandled`, stt shows a note when nothing was heard. /ai/control lists one
conversation per entry node with the messages to watch.
This commit is contained in:
Melvin Strobl
2026-10-19 23:23:25 +02:00
parent 4df7bdb057
commit a286cf4ceb
21 changed files with 937 additions and 184 deletions
+25 -11
View File
@@ -34,9 +34,9 @@ export const aiStatusQueryOptions = () =>
})
/**
* The wren nodes the Control tab can talk to. Under the flows prefix: the
* socket invalidates it on every rebuild, so a node published anywhere shows
* up without a reload.
* The conversations the Control tab can hold, one per wren or assistant node a
* command reaches first. Under the flows prefix: the socket invalidates it on
* every rebuild, so a node published anywhere shows up without a reload.
*/
export const controlNodesQueryOptions = () =>
queryOptions({
@@ -45,8 +45,8 @@ export const controlNodesQueryOptions = () =>
})
/**
* What the panel is for: building flows with the assistant, or commanding a
* wren node in one. A tab is there when there is something behind it, and the
* What the panel is for: building flows with the assistant, or commanding the
* wren and assistant nodes in one. A tab is there when there is something behind it, and the
* strip only draws once there is a choice to make.
*/
const TABS: readonly (readonly [ChatTab, string])[] = [
@@ -103,7 +103,7 @@ function fromTranscript(transcript: ChatItem[]): Item[] {
}
/**
* The way into the Control tab from Home, there only while a wren node is.
* The way into the Control tab from Home, there only while a conversation is.
* Shaped like the sidebar trigger, which it mirrors on a phone.
*/
export function ControlTrigger({ className }: { className?: string }) {
@@ -371,7 +371,7 @@ export function ChatPanel() {
{shown.length === 0 && !waiting ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{controlling
? "Type a command, like turn on the lights in the kitchen. What it did comes back here."
? "Type a command, like turn on the kitchen light. What was said to the flow and what it did show up here."
: "Describe a flow to build, a change to make, or ask what a failure means. Drafts land in the editor for you to publish."}
</p>
) : null}
@@ -407,6 +407,18 @@ function Message({ item }: { item: Item }) {
</Suspense>
</div>
)
case "heard":
return (
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-muted px-3 py-1.5 text-sm text-muted-foreground">
{item.text}
</div>
)
case "reply":
return (
<div className="min-w-0 max-w-full self-start whitespace-pre-wrap break-words px-1 text-sm">
{item.text}
</div>
)
case "error":
return <div className="px-1 text-xs text-destructive">{item.text}</div>
case "actions":
@@ -431,7 +443,7 @@ function Message({ item }: { item: Item }) {
}
}
/** A wren node's answer: each call, where it went, and whether it was applied. */
/** What a node set: each call, where it went, and whether it was applied. */
function Actions({ actions }: { actions: Action[] }) {
if (actions.length === 0) {
return (
@@ -462,9 +474,11 @@ function Actions({ actions }: { actions: Action[] }) {
: " (nothing wired for it)"}
</div>
))}
<div className="text-muted-foreground">
confidence {actions[0].confidence.toFixed(2)}
</div>
{typeof actions[0].confidence === "number" ? (
<div className="text-muted-foreground">
confidence {actions[0].confidence.toFixed(2)}
</div>
) : null}
</div>
)
}