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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user