610 lines
19 KiB
TypeScript
610 lines
19 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 { 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>
|
||
)
|
||
}
|