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.
237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
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
|