Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
A provider set under Settings (OpenAI-compatible; key encrypted in flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs the FastMCP tools in-process as the person chatting, streams the rounds over SSE and keeps one transcript per user. The panel floats on any screen and shares the editor's lane: a settings panel keeps the top half, the chat the bottom. The MCP client is now built once, for the endpoint and the chat. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
308 lines
9.6 KiB
TypeScript
308 lines
9.6 KiB
TypeScript
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { useRouterState } from "@tanstack/react-router"
|
|
import { Bot, 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 { useIsMobile } from "@/hooks/useMobile"
|
|
import { cn } from "@/lib/utils"
|
|
import { chatStore, useChat } from "./chatStore"
|
|
import { type ChatEvent, streamTurn } from "./stream"
|
|
|
|
export const aiStatusQueryOptions = () =>
|
|
queryOptions({
|
|
queryKey: ["ai", "status"],
|
|
queryFn: () => AiService.readAiStatus(),
|
|
staleTime: 60_000,
|
|
})
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
const TABS = [["build", "Build"]] as const
|
|
|
|
type Item =
|
|
| { kind: "user" | "assistant" | "error"; text: string }
|
|
| {
|
|
kind: "tool"
|
|
name: string
|
|
args?: Record<string, unknown>
|
|
ok?: boolean
|
|
error?: string
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** 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 { open, panels } = useChat()
|
|
const isMobile = useIsMobile()
|
|
const queryClient = useQueryClient()
|
|
const status = useQuery(aiStatusQueryOptions())
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (!open || loaded) return
|
|
AiService.readChat()
|
|
.then((transcript) => setItems(fromTranscript(transcript)))
|
|
.finally(() => setLoaded(true))
|
|
}, [open, loaded])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
|
|
useEffect(() => {
|
|
bottom.current?.scrollIntoView({ block: "end" })
|
|
}, [items.length, busy])
|
|
|
|
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 (!status.data?.available) return null
|
|
|
|
return (
|
|
<SidePanel
|
|
open={open}
|
|
label="Assistant"
|
|
testId="chat-panel"
|
|
bodyKey="chat"
|
|
fixed
|
|
stacked={panels > 0 && !isMobile ? "bottom" : false}
|
|
onClose={chatStore.close}
|
|
header={
|
|
<>
|
|
<Bot className="size-4 shrink-0 text-muted-foreground" />
|
|
{TABS.length > 1 ? (
|
|
<Segmented
|
|
value={TABS[0][0]}
|
|
options={TABS}
|
|
label="What to talk about"
|
|
onChange={() => {}}
|
|
/>
|
|
) : (
|
|
<span className="text-sm font-medium">Assistant</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}
|
|
aria-label="Clear the conversation"
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</>
|
|
}
|
|
footer={
|
|
<form
|
|
className="flex items-center gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
void send()
|
|
}}
|
|
>
|
|
<Input
|
|
value={draft}
|
|
placeholder={
|
|
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()}
|
|
aria-label="Send"
|
|
>
|
|
<Send />
|
|
</Button>
|
|
)}
|
|
</form>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-2 p-4">
|
|
{items.length === 0 && !busy ? (
|
|
<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.
|
|
</p>
|
|
) : null}
|
|
{items.map((item, index) => (
|
|
<Message key={index} item={item} />
|
|
))}
|
|
{busy ? (
|
|
<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 "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>
|
|
)
|
|
}
|
|
}
|