450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|