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
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:
@@ -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
|
||||
Reference in New Issue
Block a user