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
+76 -20
View File
@@ -12,7 +12,7 @@ import json
import logging
import uuid
from collections.abc import AsyncIterator
from typing import Any
from typing import Any, Literal
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -29,7 +29,7 @@ from fluksio.api.deps import (
)
from fluksio.core.config import settings
from fluksio.flow.messages import qualify
from fluksio.flow.schemas import NAME_PATTERN
from fluksio.flow.schemas import NAME_PATTERN, NodeDef
from fluksio.mcp import server as tools
logger = logging.getLogger(__name__)
@@ -77,15 +77,28 @@ class ChatItem(BaseModel):
error: str | None = None
type WatchKind = Literal["partial", "text", "reply", "actions"]
class ControlWatch(BaseModel):
"""A message the conversation shows, as one kind of line."""
msg: str
kind: WatchKind
#: The node whose values on it count. Empty counts every value, as the
#: prompt does: a command typed here or spoken to a device.
node: str = ""
class ControlNode(BaseModel):
"""A wren node the chat can talk to: where it sends and where it reads."""
"""A conversation the chat can hold: where a command goes, and what to show."""
#: Qualified, ``flow.node``.
id: str
flow: str
title: str
prompt_msg: str
actions_msg: str
watch: list[ControlWatch]
class TurnBody(BaseModel):
@@ -173,33 +186,75 @@ def read_ai_status(_: CurrentUser) -> Any:
return AIStatus(available=True, model=config.model)
#: The node types a command can be typed to.
_TALKERS = ("wren", "assistant")
def _wired(flow: str, node: NodeDef, port: str) -> str:
"""The message a node's port is wired to, qualified, or empty."""
for spec in [*node.requires, *node.provides]:
if spec.port == port and spec.name:
return qualify(flow, spec.name)
return ""
@router.get("/control", response_model=list[ControlNode])
def read_control_nodes(_: CurrentUser, controller: FlowControllerDep) -> Any:
"""The wren nodes of the running live flows, for the chat's Control tab.
"""The conversations of the running live flows, for the chat's Control tab.
Only those with both ends wired: the chat publishes the command on the
prompt's message and reads the answer off the actions'.
One per wren or assistant node a command reaches first. A node whose
prompt is another's ``unhandled`` answers in that one's conversation,
after it. The conversation shows the prompt, the words heard so far (the
``partial`` of a ``stt`` node writing the prompt), and each node's
``reply`` and ``actions``, so typed and spoken commands are one.
"""
found = []
for flow in controller.store.read_all():
if flow.mode != "live" or flow.name in controller.disabled:
continue
for node in flow.nodes:
if node.type != "wren":
talkers = [
node
for node in flow.nodes
if node.type in _TALKERS and _wired(flow.name, node, "prompt")
]
passed = {_wired(flow.name, node, "unhandled") for node in talkers}
for node in talkers:
prompt = _wired(flow.name, node, "prompt")
if prompt in passed:
continue
ends = {
spec.port: qualify(flow.name, spec.name)
for spec in [*node.requires, *node.provides]
if spec.port in ("prompt", "actions") and spec.name
}
if len(ends) == 2:
watch = [ControlWatch(msg=prompt, kind="text")]
watch += [
ControlWatch(msg=partial, kind="partial", node=f"{flow.name}.{stt.id}")
for stt in flow.nodes
if stt.type == "stt"
and _wired(flow.name, stt, "text") == prompt
and (partial := _wired(flow.name, stt, "partial"))
]
chain = [node]
for talker in chain:
unhandled = _wired(flow.name, talker, "unhandled")
chain += [
other
for other in talkers
if unhandled
and _wired(flow.name, other, "prompt") == unhandled
and other not in chain
]
answers: tuple[WatchKind, ...] = ("reply", "actions")
watch += [
ControlWatch(msg=msg, kind=kind, node=f"{flow.name}.{talker.id}")
for talker in chain
for kind in answers
if (msg := _wired(flow.name, talker, kind))
]
if any(w.kind in answers for w in watch):
found.append(
ControlNode(
id=f"{flow.name}.{node.id}",
flow=flow.name,
title=node.title or node.id,
prompt_msg=ends["prompt"],
actions_msg=ends["actions"],
title=f"{flow.title or flow.name} · {node.title or node.id}",
prompt_msg=prompt,
watch=watch,
)
)
return found
@@ -280,8 +335,9 @@ def chat(
config,
messages,
body.message,
flow=body.flow,
authorization=f"Bearer {token}",
tools=await agent.tool_specs(),
system=agent.system_prompt(body.flow),
call_tool=agent.mcp_caller(f"Bearer {token}"),
):
yield _sse(event)
except Exception as exc: # noqa: BLE001 - the stream is the error channel