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

610 lines
19 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 { lazy, Suspense, 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 {
chips,
type History,
loadHistory,
record,
STARTERS,
saveHistory,
} from "./prompts"
import { type ChatEvent, streamTurn } from "./stream"
import { type Action, type ControlItem, useControl } from "./useControl"
/** The renderer arrives with the first answer, not with the app. */
const Markdown = lazy(() => import("./Markdown"))
export const aiStatusQueryOptions = () =>
queryOptions({
queryKey: ["ai", "status"],
queryFn: () => AiService.readAiStatus(),
staleTime: 60_000,
})
/**
* The conversations the Control tab can hold, one per wren or assistant node a
* command reaches first. 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 the
* wren and assistant nodes 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 conversation 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>
)
}
/**
* The phone header's one way into the chat. Home opens the Control tab, where
* its conversation is, and any other page the assistant; the panel falls back
* to whichever of the two there is.
*/
export function ChatTrigger({ tab }: { tab: ChatTab }) {
const available = useQuery(aiStatusQueryOptions()).data?.available
const nodes = useQuery(controlNodesQueryOptions()).data ?? []
if (!available && nodes.length === 0) return null
return (
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={() => chatStore.open(tab)}
aria-label="Chat"
title="Chat"
data-testid="chat-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 [history, setHistory] = useState<History>(loadHistory)
const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
const input = useRef<HTMLInputElement>(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])
const tail = shown[shown.length - 1]
const tailLength = tail && "text" in tail ? tail.text.length : 0
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list or a streamed answer grows, not on every render.
useEffect(() => {
// Only the panel's body moves: `scrollIntoView` would scroll every
// ancestor too, which is how the logs panel slid the code editor away.
const body = bottom.current?.closest("[data-slot=side-panel-body]")
body?.scrollTo({ top: body.scrollHeight })
}, [shown.length, tailLength, waiting])
/** Counts a sent message toward its tab's quick prompts. */
const remember = (which: ChatTab, text: string) => {
const next = {
...history,
[which]: record(history[which], text, Date.now()),
}
setHistory(next)
saveHistory(next)
}
/** Puts a message into the input to edit and send, whatever was there. */
const reuse = (text: string) => {
setDraft(text)
input.current?.focus()
// A double-click selects the word under it, which would only get in the way.
window.getSelection()?.removeAllRanges()
}
const send = async () => {
const text = draft.trim()
if (!text || busy) return
remember("build", text)
setDraft("")
setBusy(true)
setItems((prev) => [...prev, { kind: "user", text }])
const abort = new AbortController()
controller.current = abort
const onEvent = (event: ChatEvent) => {
switch (event.type) {
// A round's text grows in the last item and is settled by `message`;
// a tool call or the next turn always comes between two rounds.
case "delta":
case "message":
setItems((prev) => {
const last = prev[prev.length - 1]
if (last?.kind !== "assistant")
return [...prev, { kind: "assistant", text: event.content }]
const text =
event.type === "delta" ? last.text + event.content : event.content
return [...prev.slice(0, -1), { kind: "assistant", text }]
})
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={controlling ? "Control" : "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={
<div className="grid gap-2">
{draft.trim() ? null : (
<div
className="flex gap-2 overflow-x-auto py-1 -my-1"
data-testid="chat-prompts"
>
{chips(history[tab], STARTERS[tab]).map((prompt) => (
<Button
key={prompt}
type="button"
variant="outline"
size="sm-touch"
className="max-w-64"
onClick={() => reuse(prompt)}
data-testid="chat-prompt"
>
<span className="truncate">{prompt}</span>
</Button>
))}
</div>
)}
<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
remember("control", text)
setDraft("")
void control.send(text)
}}
>
<Input
ref={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>
}
>
<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 or pick one below. What was said to the flow and what it did show up 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} onReuse={reuse} />
))}
{waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
</div>
</SidePanel>
)
}
const DOUBLE_TAP_MS = 300
/**
* What somebody said, which a double-click or a double-tap puts back into the
* input. A touch screen gets no `dblclick` from every browser, so two taps in
* a moment are read as one as well.
*/
function Said({
text,
className,
onReuse,
}: {
text: string
className: string
onReuse: (text: string) => void
}) {
const lastTap = useRef(0)
return (
// biome-ignore lint/a11y/noStaticElementInteractions: a shortcut; the text stays reachable by selecting it.
<div
className={cn(
"max-w-[85%] touch-manipulation self-end whitespace-pre-wrap break-words rounded-lg px-3 py-1.5 text-sm",
className,
)}
title="Double-click to reuse"
onDoubleClick={() => onReuse(text)}
onPointerUp={(event) => {
if (event.pointerType !== "touch") return
const double = event.timeStamp - lastTap.current < DOUBLE_TAP_MS
lastTap.current = double ? 0 : event.timeStamp
if (double) onReuse(text)
}}
>
{text}
</div>
)
}
function Message({
item,
onReuse,
}: {
item: Item
onReuse: (text: string) => void
}) {
switch (item.kind) {
case "user":
return (
<Said
text={item.text}
className="bg-primary text-primary-foreground"
onReuse={onReuse}
/>
)
case "assistant":
return (
<div className="min-w-0 max-w-full self-start px-1 text-sm">
<Suspense
fallback={
<div className="whitespace-pre-wrap break-words">{item.text}</div>
}
>
<Markdown text={item.text} />
</Suspense>
</div>
)
case "heard":
return (
<Said
text={item.text}
className="bg-muted text-muted-foreground"
onReuse={onReuse}
/>
)
case "reply":
return (
<div className="min-w-0 max-w-full 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>
)
}
}
/** What a node set: 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>
))}
{typeof actions[0].confidence === "number" ? (
<div className="text-muted-foreground">
confidence {actions[0].confidence.toFixed(2)}
</div>
) : null}
</div>
)
}