Files
app/frontend/src/components/Chat/ChatPanel.tsx
T

450 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
import { useRouterState } from "@tanstack/react-router"
import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { AiService, type ChatItem } from "@/client"
import { flowKeys } from "@/components/Flow/queries"
import { SidePanel } from "@/components/Flow/SidePanel"
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 { type ChatTab, chatStore, useChat } from "./chatStore"
import { type ChatEvent, streamTurn } from "./stream"
import { type Action, type ControlItem, useControl } from "./useControl"
export const aiStatusQueryOptions = () =>
queryOptions({
queryKey: ["ai", "status"],
queryFn: () => AiService.readAiStatus(),
staleTime: 60_000,
})
/**
* 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.
*/
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 }
| {
kind: "tool"
name: string
args?: Record<string, unknown>
ok?: boolean
error?: string
}
| ControlItem
/** The stored transcript, in the shape the live turn appends. */
function fromTranscript(transcript: ChatItem[]): Item[] {
const items: Item[] = []
// A tool's answer follows its call; the earliest unanswered call is its.
const pending: number[] = []
for (const message of transcript) {
if (message.role === "user" && message.content) {
items.push({ kind: "user", text: message.content })
} else if (message.role === "assistant") {
if (message.content)
items.push({ kind: "assistant", text: message.content })
for (const call of message.tool_calls ?? []) {
const fn = (call.function ?? {}) as {
name?: string
arguments?: string
}
let args: Record<string, unknown> | undefined
try {
args = JSON.parse(fn.arguments ?? "{}") as Record<string, unknown>
} catch {
args = undefined
}
pending.push(items.length)
items.push({ kind: "tool", name: fn.name ?? "tool", args })
}
} else if (message.role === "tool") {
const index = pending.shift()
const call = index === undefined ? undefined : items[index]
if (call?.kind === "tool") {
call.ok = message.ok ?? true
call.error = message.error ?? undefined
}
}
}
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)
const FLOW_PATH = /^\/flows\/([a-z][a-z0-9_]*)/
/**
* The assistant, floating over whichever screen it was opened on and sharing
* the editor's lane with its panels: when one of those is open the chat takes
* the lower half. A phone gets it as a sheet, like every other panel.
*/
export function ChatPanel() {
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]
const [items, setItems] = useState<Item[]>([])
const [loaded, setLoaded] = useState(false)
const [draft, setDraft] = useState("")
const [busy, setBusy] = useState(false)
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 || !available) return
AiService.readChat()
.then((transcript) => setItems(fromTranscript(transcript)))
.finally(() => setLoaded(true))
}, [open, loaded, available])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [shown.length, waiting])
const send = async () => {
const text = draft.trim()
if (!text || busy) return
setDraft("")
setBusy(true)
setItems((prev) => [...prev, { kind: "user", text }])
const abort = new AbortController()
controller.current = abort
const onEvent = (event: ChatEvent) => {
switch (event.type) {
case "message":
setItems((prev) => [
...prev,
{ kind: "assistant", text: event.content },
])
break
case "tool_call":
setItems((prev) => [
...prev,
{ kind: "tool", name: event.name, args: event.arguments },
])
break
case "tool_result":
setItems((prev) => {
const next = [...prev]
for (let i = next.length - 1; i >= 0; i--) {
const item = next[i]
if (item.kind === "tool" && item.ok === undefined) {
next[i] = { ...item, ok: event.ok, error: event.error }
break
}
}
return next
})
if (!isRead(event.name)) {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
}
break
case "error":
setItems((prev) => [...prev, { kind: "error", text: event.detail }])
break
}
}
await streamTurn({ message: text, flow, signal: abort.signal, onEvent })
controller.current = null
setBusy(false)
}
const clear = () => {
controller.current?.abort()
setItems([])
void AiService.clearChat()
}
if (!tab) return null
return (
<SidePanel
open={open}
label="Assistant"
testId="chat-panel"
bodyKey="chat"
fixed
stacked={panels > 0 && !isMobile ? "bottom" : false}
onClose={chatStore.close}
header={
<>
{controlling ? (
<Bird className="size-4 shrink-0 text-muted-foreground" />
) : (
<Bot className="size-4 shrink-0 text-muted-foreground" />
)}
{tabs.length > 1 ? (
<Segmented
value={tab}
options={tabs}
label="What to talk about"
testId="chat-tabs"
onChange={(next) => chatStore.show(next as ChatTab)}
/>
) : (
<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>
)}
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={controlling ? control.clear : clear}
disabled={shown.length === 0}
aria-label="Clear the conversation"
>
<Trash2 />
</Button>
</>
}
footer={
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (!controlling) {
void send()
return
}
const text = draft.trim()
if (!text || control.busy) return
setDraft("")
void control.send(text)
}}
>
<Input
value={draft}
placeholder={
controlling
? "Type a command"
: flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
}
aria-label="Message"
autoComplete="off"
data-testid="chat-input"
onChange={(event) => setDraft(event.target.value)}
/>
{busy ? (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => controller.current?.abort()}
aria-label="Stop"
>
<Square />
</Button>
) : (
<Button
type="submit"
size="icon"
className="shrink-0"
disabled={!draft.trim() || waiting}
aria-label="Send"
>
<Send />
</Button>
)}
</form>
}
>
<div className="flex flex-col gap-2 p-4">
{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."
: "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}
{shown.map((item, index) => (
<Message key={index} item={item} />
))}
{waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
</div>
</SidePanel>
)
}
function Message({ item }: { item: Item }) {
switch (item.kind) {
case "user":
return (
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{item.text}
</div>
)
case "assistant":
return (
<div className="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":
return <Actions actions={item.actions} />
case "tool":
return (
<details
className={cn(
"self-start px-1 font-mono text-xs",
item.ok === false ? "text-destructive" : "text-muted-foreground",
)}
>
<summary className="cursor-pointer">
{item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name}
</summary>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2">
{item.error ??
JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
</pre>
</details>
)
}
}
/** 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>
)
}