Add the assistant: a chat that drives the MCP tools in-process
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>
This commit is contained in:
2026-09-16 23:28:23 +02:00
co-authored by Claude Fable 5.1
parent d9630f66f5
commit cc4091166e
25 changed files with 1957 additions and 43 deletions
@@ -0,0 +1,236 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { type AIConfigPublic, AiService } from "@/client"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
/** Where the usual providers answer. Anything OpenAI-compatible will do. */
const PROVIDERS = [
["https://api.openai.com/v1", "OpenAI"],
["https://api.anthropic.com/v1", "Anthropic"],
["https://openrouter.ai/api/v1", "OpenRouter"],
["http://localhost:11434/v1", "Ollama"],
] as const
/**
* The assistant's provider: one for the whole instance, set by an operator,
* with the key kept encrypted here and never shown again.
*/
export function Assistant() {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [apiBase, setApiBase] = useState("")
const [apiKey, setApiKey] = useState("")
const [model, setModel] = useState("")
const [models, setModels] = useState<string[]>([])
const config = useQuery<AIConfigPublic>({
queryKey: ["ai", "config"],
queryFn: () => AiService.readAiConfig(),
})
useEffect(() => {
if (!config.data) return
setApiBase(config.data.api_base)
setModel(config.data.model)
}, [config.data])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ai"] })
const fetchModels = useMutation({
mutationFn: () =>
AiService.listAiModels({
requestBody: {
api_base: apiBase.trim(),
api_key: apiKey.trim() || null,
},
}),
onSuccess: (list) => {
setModels(list)
if (list.length && !list.includes(model)) setModel(list[0])
},
onError: handleError.bind(showErrorToast),
})
const save = useMutation({
mutationFn: () =>
AiService.updateAiConfig({
requestBody: {
api_base: apiBase.trim(),
api_key: apiKey.trim() || null,
model: model.trim(),
},
}),
onSuccess: () => {
setApiKey("")
showSuccessToast("Assistant ready")
invalidate()
},
onError: handleError.bind(showErrorToast),
})
const remove = useMutation({
mutationFn: () => AiService.deleteAiConfig(),
onSuccess: () => {
setApiKey("")
setModels([])
showSuccessToast("Assistant turned off")
invalidate()
},
onError: handleError.bind(showErrorToast),
})
if (!config.data) return null
const keySet = config.data.api_key_set
const preset = PROVIDERS.find(([url]) => url === apiBase)?.[0] ?? "custom"
return (
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<CardDescription>
Builds and edits flows from a chat, through the same API as agents.
Needs a model provider; everyone on this instance shares it.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="ai-provider">Provider</Label>
<Select
value={preset}
onValueChange={(value) => {
if (value !== "custom") setApiBase(value)
setModels([])
}}
>
<SelectTrigger id="ai-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(([url, name]) => (
<SelectItem key={url} value={url}>
{name}
</SelectItem>
))}
<SelectItem value="custom">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1">
<Label htmlFor="ai-base">URL</Label>
<InfoTip label="URL">
An OpenAI-compatible base, ending in /v1. A server on your own
network works too.
</InfoTip>
</div>
<Input
id="ai-base"
value={apiBase}
placeholder="https://…/v1"
autoComplete="off"
onChange={(event) => setApiBase(event.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1">
<Label htmlFor="ai-key">API key</Label>
<InfoTip label="API key">
Stored encrypted on this instance and never shown again. Leave it
empty to keep the one saved.
</InfoTip>
</div>
<PasswordInput
id="ai-key"
value={apiKey}
placeholder={keySet ? (config.data.api_key_masked ?? "saved") : ""}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ai-model">Model</Label>
<div className="flex gap-2">
{models.length ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id="ai-model" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="ai-model"
value={model}
placeholder="Fetch the list, or type a name"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setModel(event.target.value)}
/>
)}
<LoadingButton
variant="outline"
className="shrink-0 rounded-full"
loading={fetchModels.isPending}
disabled={!apiBase.trim() || (!apiKey.trim() && !keySet)}
onClick={() => fetchModels.mutate()}
>
Fetch models
</LoadingButton>
</div>
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
variant="brand"
className="rounded-full"
loading={save.isPending}
disabled={
!apiBase.trim() || !model.trim() || (!apiKey.trim() && !keySet)
}
onClick={() => save.mutate()}
data-testid="save-assistant"
>
Save
</LoadingButton>
{keySet ? (
<Button
variant="outline"
className="rounded-full"
onClick={() => remove.mutate()}
>
Turn off
</Button>
) : null}
</div>
</CardContent>
</Card>
)
}
export default Assistant