Talk to wren nodes from the chat's Control tab, opened from Home

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-05 20:59:11 +02:00
co-authored by Claude Opus 5.5
parent d8edce55f9
commit 673061d9fb
11 changed files with 417 additions and 42 deletions
+50 -1
View File
@@ -21,8 +21,14 @@ from pydantic import BaseModel, Field
from fluksio.ai import agent, client
from fluksio.ai import config as ai_config
from fluksio.api.deps import CurrentUser, TokenDep, get_current_active_superuser
from fluksio.api.deps import (
CurrentUser,
FlowControllerDep,
TokenDep,
get_current_active_superuser,
)
from fluksio.core.config import settings
from fluksio.flow.messages import qualify
from fluksio.flow.schemas import NAME_PATTERN
from fluksio.mcp import server as tools
@@ -71,6 +77,17 @@ class ChatItem(BaseModel):
error: str | None = None
class ControlNode(BaseModel):
"""A wren node the chat can talk to: where it sends and where it reads."""
#: Qualified, ``flow.node``.
id: str
flow: str
title: str
prompt_msg: str
actions_msg: str
class TurnBody(BaseModel):
message: str = Field(min_length=1, max_length=20_000)
#: The flow open in the editor, when the chat is opened from one.
@@ -156,6 +173,38 @@ def read_ai_status(_: CurrentUser) -> Any:
return AIStatus(available=True, model=config.model)
@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.
Only those with both ends wired: the chat publishes the command on the
prompt's message and reads the answer off the actions'.
"""
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":
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:
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"],
)
)
return found
def _chat_file(user_id: uuid.UUID) -> Any:
return settings.CHATS_DIR / f"{user_id}.json"
+29
View File
@@ -565,6 +565,35 @@ export const ChatItemSchema = {
title: 'ChatItem'
} as const;
export const ControlNodeSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
flow: {
type: 'string',
title: 'Flow'
},
title: {
type: 'string',
title: 'Title'
},
prompt_msg: {
type: 'string',
title: 'Prompt Msg'
},
actions_msg: {
type: 'string',
title: 'Actions Msg'
}
},
type: 'object',
required: ['id', 'flow', 'title', 'prompt_msg', 'actions_msg'],
title: 'ControlNode',
description: 'A wren node the chat can talk to: where it sends and where it reads.'
} as const;
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list', 'artifact', 'image', 'audio', 'video'],
File diff suppressed because one or more lines are too long
+13
View File
@@ -142,6 +142,17 @@ export type ChatItem = {
error?: (string | null);
};
/**
* A wren node the chat can talk to: where it sends and where it reads.
*/
export type ControlNode = {
id: string;
flow: string;
title: string;
prompt_msg: string;
actions_msg: string;
};
/**
* A dashboard as stored, and as the API hands it over.
*/
@@ -1461,6 +1472,8 @@ export type AiListAiModelsResponse = (Array<(string)>);
export type AiReadAiStatusResponse = (AIStatus);
export type AiReadControlNodesResponse = (Array<ControlNode>);
export type AiReadChatResponse = (Array<ChatItem>);
export type AiChatData = {
+174 -32
View File
@@ -1,6 +1,6 @@
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
import { useRouterState } from "@tanstack/react-router"
import { Bot, Send, Square, Trash2 } from "lucide-react"
import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { AiService, type ChatItem } from "@/client"
@@ -10,10 +10,18 @@ import { Button } from "@/components/ui/button"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
import { Input } from "@/components/ui/input"
import { Segmented } from "@/components/ui/segmented"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import { chatStore, useChat } from "./chatStore"
import { type ChatTab, chatStore, useChat } from "./chatStore"
import { type ChatEvent, streamTurn } from "./stream"
import { type Action, type ControlItem, useControl } from "./useControl"
export const aiStatusQueryOptions = () =>
queryOptions({
@@ -23,11 +31,25 @@ export const aiStatusQueryOptions = () =>
})
/**
* What the panel is for. One tab today; the second, talking to a wren node in
* a flow, joins here when that node exists, and the strip only draws once
* there is a choice to make.
* 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.
*/
const TABS = [["build", "Build"]] as const
export const controlNodesQueryOptions = () =>
queryOptions({
queryKey: [...flowKeys.all, "control-nodes"],
queryFn: () => AiService.readControlNodes(),
})
/**
* 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
* strip only draws once there is a choice to make.
*/
const TABS: readonly (readonly [ChatTab, string])[] = [
["build", "Build"],
["control", "Control"],
]
type Item =
| { kind: "user" | "assistant" | "error"; text: string }
@@ -38,6 +60,7 @@ type Item =
ok?: boolean
error?: string
}
| ControlItem
/** The stored transcript, in the shape the live turn appends. */
function fromTranscript(transcript: ChatItem[]): Item[] {
@@ -76,6 +99,33 @@ function fromTranscript(transcript: ChatItem[]): Item[] {
return items
}
/**
* The way into the Control tab from Home, there only while a wren node is.
* Shaped like the sidebar trigger, which it mirrors on a phone.
*/
export function ControlTrigger({ className }: { className?: string }) {
const { open, tab } = useChat()
const nodes = useQuery(controlNodesQueryOptions()).data ?? []
if (nodes.length === 0) return null
return (
<Button
variant="ghost"
size="icon"
className={cn("size-7 text-muted-foreground", className)}
onClick={() =>
open && tab === "control"
? chatStore.close()
: chatStore.open("control")
}
aria-label="Chat"
title="Chat"
data-testid="control-trigger"
>
<MessageCircle />
</Button>
)
}
/** A read leaves nothing to refetch; anything else may have changed a flow. */
const isRead = (name: string) => /^(list|get)_/.test(name)
@@ -87,10 +137,15 @@ const FLOW_PATH = /^\/flows\/([a-z][a-z0-9_]*)/
* the lower half. A phone gets it as a sheet, like every other panel.
*/
export function ChatPanel() {
const { open, panels } = useChat()
const chat = useChat()
const { open, panels } = chat
const isMobile = useIsMobile()
const queryClient = useQueryClient()
const status = useQuery(aiStatusQueryOptions())
const nodes = useQuery(controlNodesQueryOptions()).data ?? []
const [nodeId, setNodeId] = useState<string>()
const node = nodes.find((n) => n.id === nodeId) ?? nodes[0]
const control = useControl(node)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const flow = FLOW_PATH.exec(pathname)?.[1]
@@ -101,17 +156,26 @@ export function ChatPanel() {
const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
const available = Boolean(status.data?.available)
const tabs = TABS.filter(([key]) =>
key === "build" ? available : nodes.length > 0,
)
const tab = tabs.some(([key]) => key === chat.tab) ? chat.tab : tabs[0]?.[0]
const controlling = tab === "control"
const shown: Item[] = controlling ? control.items : items
const waiting = controlling ? control.busy : busy
useEffect(() => {
if (!open || loaded) return
if (!open || loaded || !available) return
AiService.readChat()
.then((transcript) => setItems(fromTranscript(transcript)))
.finally(() => setLoaded(true))
}, [open, loaded])
}, [open, loaded, available])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [items.length, busy])
}, [shown.length, waiting])
const send = async () => {
const text = draft.trim()
@@ -167,7 +231,7 @@ export function ChatPanel() {
void AiService.clearChat()
}
if (!status.data?.available) return null
if (!tab) return null
return (
<SidePanel
@@ -180,26 +244,54 @@ export function ChatPanel() {
onClose={chatStore.close}
header={
<>
<Bot className="size-4 shrink-0 text-muted-foreground" />
{TABS.length > 1 ? (
{controlling ? (
<Bird className="size-4 shrink-0 text-muted-foreground" />
) : (
<Bot className="size-4 shrink-0 text-muted-foreground" />
)}
{tabs.length > 1 ? (
<Segmented
value={TABS[0][0]}
options={TABS}
value={tab}
options={tabs}
label="What to talk about"
onChange={() => {}}
testId="chat-tabs"
onChange={(next) => chatStore.show(next as ChatTab)}
/>
) : (
<span className="text-sm font-medium">Assistant</span>
<span className="text-sm font-medium">
{controlling ? "Control" : "Assistant"}
</span>
)}
{controlling && nodes.length > 1 ? (
<div className="min-w-0 flex-1">
<Select value={node?.id} onValueChange={setNodeId}>
<SelectTrigger
size="sm"
className="max-w-full text-xs"
aria-label="Which node to talk to"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{nodes.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{controlling ? node?.title : status.data?.model}
</span>
)}
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{status.data.model}
</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={clear}
disabled={items.length === 0}
onClick={controlling ? control.clear : clear}
disabled={shown.length === 0}
aria-label="Clear the conversation"
>
<Trash2 />
@@ -211,15 +303,24 @@ export function ChatPanel() {
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
void send()
if (!controlling) {
void send()
return
}
const text = draft.trim()
if (!text || control.busy) return
setDraft("")
void control.send(text)
}}
>
<Input
value={draft}
placeholder={
flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
controlling
? "Type a command"
: flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
}
aria-label="Message"
autoComplete="off"
@@ -242,7 +343,7 @@ export function ChatPanel() {
type="submit"
size="icon"
className="shrink-0"
disabled={!draft.trim()}
disabled={!draft.trim() || waiting}
aria-label="Send"
>
<Send />
@@ -252,16 +353,17 @@ export function ChatPanel() {
}
>
<div className="flex flex-col gap-2 p-4">
{items.length === 0 && !busy ? (
{shown.length === 0 && !waiting ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Describe a flow to build, a change to make, or ask what a failure
means. Drafts land in the editor for you to publish.
{controlling
? "Type a command, like turn on the lights in the kitchen. What it did comes back 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}
{items.map((item, index) => (
{shown.map((item, index) => (
<Message key={index} item={item} />
))}
{busy ? (
{waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
@@ -286,6 +388,8 @@ function Message({ item }: { item: Item }) {
)
case "error":
return <div className="px-1 text-xs text-destructive">{item.text}</div>
case "actions":
return <Actions actions={item.actions} />
case "tool":
return (
<details
@@ -305,3 +409,41 @@ function Message({ item }: { item: Item }) {
)
}
}
/** A wren node's answer: each call, where it went, and whether it was applied. */
function Actions({ actions }: { actions: Action[] }) {
if (actions.length === 0) {
return (
<div className="self-start px-1 text-sm text-muted-foreground">
Nothing to do.
</div>
)
}
return (
<div className="grid min-w-0 gap-1 self-start px-1 font-mono text-xs">
{actions.map((action, index) => (
<div
key={index}
className={cn(
"break-words",
!action.applied && "text-muted-foreground",
)}
>
{action.applied ? "✓" : "–"} {action.call}
{action.message
? ` → ${action.message} = ${JSON.stringify(action.value)}`
: ""}
{action.delay_s ? ` in ${Math.round(action.delay_s / 60)} min` : ""}
{action.applied
? ""
: action.message
? " (not applied)"
: " (nothing wired for it)"}
</div>
))}
<div className="text-muted-foreground">
confidence {actions[0].confidence.toFixed(2)}
</div>
</div>
)
}
+10 -4
View File
@@ -1,16 +1,20 @@
import { useSyncExternalStore } from "react"
/** What the panel is talking to: the assistant, or a wren node in a flow. */
export type ChatTab = "build" | "control"
/**
* Whether the assistant is open, and whether another panel is open beside it.
* Whether the assistant is open, on which tab, and whether another panel is
* open beside it.
*
* Module state rather than context because the chat is mounted once in the
* sidebar and toggled from there, while the editor's own panels report in
* from a different tree: the two only have to agree on who takes which half
* of the lane.
*/
type State = { open: boolean; panels: number }
type State = { open: boolean; tab: ChatTab; panels: number }
let state: State = { open: false, panels: 0 }
let state: State = { open: false, tab: "build", panels: 0 }
const listeners = new Set<() => void>()
function set(next: Partial<State>) {
@@ -19,7 +23,9 @@ function set(next: Partial<State>) {
}
export const chatStore = {
open: () => set({ open: true }),
/** Open it, on a given tab or on whichever was last shown. */
open: (tab?: ChatTab) => set(tab ? { open: true, tab } : { open: true }),
show: (tab: ChatTab) => set({ tab }),
close: () => set({ open: false }),
toggle: () => set({ open: !state.open }),
/** A floating panel is open; returns how to say it closed again. */
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react"
import { ApiError, type ControlNode, MessagesService } from "@/client"
import {
liveStore,
useLiveValue,
useNodeFailure,
} from "@/components/Flow/liveStore"
/** One call the node answered with, as its `actions` output records it. */
export type Action = {
message: string
value: unknown
applied: boolean
delay_s: number | null
call: string
confidence: number
}
export type ControlItem =
| { kind: "user" | "error"; text: string }
| { kind: "actions"; actions: Action[] }
/** How long a command may go unanswered before the chat says so. */
const TIMEOUT_MS = 15_000
/**
* Talking to a wren node: a command goes out on its prompt's message, and the
* answer is the next value of its actions' message — or its next failure.
*
* The answer is told apart by the engine's own timestamps, taken before the
* command leaves, so no clock on this side is compared with the server's. The
* history lives here only, and a reload starts it over.
*/
export function useControl(node: ControlNode | undefined) {
const [items, setItems] = useState<ControlItem[]>([])
// What "newer" means for this command: the last answer and the last failure
// before it was sent.
const [pending, setPending] = useState<{
answer: number
failure: number
} | null>(null)
const answer = useLiveValue(node?.actions_msg)
const failure = useNodeFailure(node?.id ?? "")
useEffect(() => {
if (!pending) return
if ((answer?.ts ?? 0) > pending.answer) {
const actions = Array.isArray(answer?.value)
? (answer.value as Action[])
: []
setItems((prev) => [...prev, { kind: "actions", actions }])
setPending(null)
} else if (failure && failure.ts > pending.failure) {
setItems((prev) => [...prev, { kind: "error", text: failure.error }])
setPending(null)
}
}, [pending, answer, failure])
useEffect(() => {
if (!pending) return
const timer = setTimeout(() => {
setItems((prev) => [
...prev,
{
kind: "error",
text: "No answer yet. Check that its flow is running.",
},
])
setPending(null)
}, TIMEOUT_MS)
return () => clearTimeout(timer)
}, [pending])
const send = async (text: string) => {
if (!node) return
setItems((prev) => [...prev, { kind: "user", text }])
setPending({
answer: liveStore.getValue(node.actions_msg)?.ts ?? 0,
failure: liveStore.getFailure(node.id)?.ts ?? 0,
})
try {
await MessagesService.publishMessage({
name: node.prompt_msg,
requestBody: { value: text, source_kind: "chat", source_label: "Chat" },
})
} catch (error) {
const body = error instanceof ApiError ? error.body : undefined
const detail = (body as { detail?: unknown } | undefined)?.detail
setItems((prev) => [
...prev,
{
kind: "error",
text: typeof detail === "string" ? detail : String(error),
},
])
setPending(null)
}
}
return { items, busy: pending !== null, send, clear: () => setItems([]) }
}
@@ -1,6 +1,7 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
Bell,
Bird,
Braces,
Bug,
Clock,
@@ -56,6 +57,7 @@ const NODE_ICONS = {
exec: Terminal,
file: FileText,
ntfy: Bell,
wren: Bird,
} as const
// One dot says everything about a node's state. Idle nodes carry no dot at all,
+1 -1
View File
@@ -13,7 +13,7 @@ import { request } from "@/client/core/request"
/** Who caused a value. The canvas needs it to pulse the right edge. */
export type ValueSource = {
kind: "node" | "dashboard" | "flow" | "agent" | "api"
kind: "node" | "dashboard" | "flow" | "agent" | "chat" | "api"
id: string
label: string
detail?: string
+11 -2
View File
@@ -1,5 +1,11 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import {
createFileRoute,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router"
import { ControlTrigger } from "@/components/Chat/ChatPanel"
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
import ErrorComponent from "@/components/Common/ErrorComponent"
import { Footer } from "@/components/Common/Footer"
@@ -31,6 +37,7 @@ function Layout() {
// Dashboards live in this shell and read live values, so the socket belongs
// here rather than only inside the editor.
useFlowSocket()
const home = useRouterState({ select: (s) => s.location.pathname === "/" })
return (
<SidebarProvider className="bg-card">
@@ -40,9 +47,11 @@ function Layout() {
served through a portal: it raises the offline notification. */}
<ConnectionNotice />
{/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */}
sidebar on screen to carry it. Home's chat sits opposite, where
the brain view has it on a wider screen. */}
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
<SidebarTrigger className="-ml-1 text-muted-foreground" />
{home ? <ControlTrigger className="-mr-1 ml-auto" /> : null}
</header>
<main className="flex-1 p-4 sm:p-6 md:p-8">
<div className="mx-auto max-w-7xl">
+8 -1
View File
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { ControlTrigger } from "@/components/Chat/ChatPanel"
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
@@ -62,7 +63,13 @@ function Home() {
{/* Nothing wired up yet means nothing to draw, and the band would still
hold a screenful of empty space above the flows table. Node count
rather than flow count: a flow made a minute ago has none. */}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? (
<div className="relative">
<BrainView />
{/* A phone has it in the header instead, opposite the sidebar's. */}
<ControlTrigger className="absolute top-0 right-0 hidden md:inline-flex" />
</div>
) : null}
{/* One row across the full width rather than a column beside the flows:
a dashboard tile is a picture, and a picture wants to be wide. Past