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
+209
View File
@@ -1,5 +1,128 @@
// This file is auto-generated by @hey-api/openapi-ts
export const AIConfigPublicSchema = {
properties: {
api_base: {
type: 'string',
title: 'Api Base'
},
model: {
type: 'string',
title: 'Model'
},
max_rounds: {
type: 'integer',
title: 'Max Rounds'
},
api_key_set: {
type: 'boolean',
title: 'Api Key Set'
},
api_key_masked: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Api Key Masked'
}
},
type: 'object',
required: ['api_base', 'model', 'max_rounds', 'api_key_set', 'api_key_masked'],
title: 'AIConfigPublic'
} as const;
export const AIConfigUpdateSchema = {
properties: {
api_base: {
type: 'string',
maxLength: 512,
minLength: 1,
title: 'Api Base'
},
api_key: {
anyOf: [
{
type: 'string',
maxLength: 512
},
{
type: 'null'
}
],
title: 'Api Key'
},
model: {
type: 'string',
maxLength: 128,
title: 'Model',
default: ''
},
max_rounds: {
type: 'integer',
maximum: 50,
minimum: 1,
title: 'Max Rounds',
default: 12
}
},
type: 'object',
required: ['api_base'],
title: 'AIConfigUpdate'
} as const;
export const AIModelsBodySchema = {
properties: {
api_base: {
type: 'string',
maxLength: 512,
minLength: 1,
title: 'Api Base'
},
api_key: {
anyOf: [
{
type: 'string',
maxLength: 512
},
{
type: 'null'
}
],
title: 'Api Key'
}
},
type: 'object',
required: ['api_base'],
title: 'AIModelsBody'
} as const;
export const AIStatusSchema = {
properties: {
available: {
type: 'boolean',
title: 'Available'
},
model: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Model'
}
},
type: 'object',
required: ['available', 'model'],
title: 'AIStatus'
} as const;
export const AlertsConfigSchema = {
properties: {
enabled: {
@@ -382,6 +505,66 @@ export const ChannelSchema = {
description: 'Somewhere to send an alert.'
} as const;
export const ChatItemSchema = {
properties: {
role: {
type: 'string',
title: 'Role'
},
content: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Content'
},
tool_calls: {
anyOf: [
{
items: {
additionalProperties: true,
type: 'object'
},
type: 'array'
},
{
type: 'null'
}
],
title: 'Tool Calls'
},
ok: {
anyOf: [
{
type: 'boolean'
},
{
type: 'null'
}
],
title: 'Ok'
},
error: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Error'
}
},
type: 'object',
required: ['role'],
title: 'ChatItem'
} as const;
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list', 'artifact', 'image', 'audio', 'video'],
@@ -3608,6 +3791,32 @@ export const TriggerRequestSchema = {
title: 'TriggerRequest'
} as const;
export const TurnBodySchema = {
properties: {
message: {
type: 'string',
maxLength: 20000,
minLength: 1,
title: 'Message'
},
flow: {
anyOf: [
{
type: 'string',
pattern: '^[a-z][a-z0-9_]*$'
},
{
type: 'null'
}
],
title: 'Flow'
}
},
type: 'object',
required: ['message'],
title: 'TurnBody'
} as const;
export const UnsubscribeSchema = {
properties: {
endpoint: {
File diff suppressed because one or more lines are too long
+68
View File
@@ -1,5 +1,30 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AIConfigPublic = {
api_base: string;
model: string;
max_rounds: number;
api_key_set: boolean;
api_key_masked: (string | null);
};
export type AIConfigUpdate = {
api_base: string;
api_key?: (string | null);
model?: string;
max_rounds?: number;
};
export type AIModelsBody = {
api_base: string;
api_key?: (string | null);
};
export type AIStatus = {
available: boolean;
model: (string | null);
};
/**
* The whole alerting setup, as stored and as the API sees it.
*/
@@ -107,6 +132,16 @@ export type Channel = {
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
export type ChatItem = {
role: string;
content?: (string | null);
tool_calls?: (Array<{
[key: string]: unknown;
}> | null);
ok?: (boolean | null);
error?: (string | null);
};
/**
* A dashboard as stored, and as the API hands it over.
*/
@@ -1236,6 +1271,11 @@ export type TriggerRequest = {
};
};
export type TurnBody = {
message: string;
flow?: (string | null);
};
export type Unsubscribe = {
endpoint: string;
};
@@ -1401,6 +1441,34 @@ export type WorkerInfo = {
ram_mb?: (number | null);
};
export type AiReadAiConfigResponse = (AIConfigPublic);
export type AiUpdateAiConfigData = {
requestBody: AIConfigUpdate;
};
export type AiUpdateAiConfigResponse = (AIConfigPublic);
export type AiDeleteAiConfigResponse = (unknown);
export type AiListAiModelsData = {
requestBody: AIModelsBody;
};
export type AiListAiModelsResponse = (Array<(string)>);
export type AiReadAiStatusResponse = (AIStatus);
export type AiReadChatResponse = (Array<ChatItem>);
export type AiChatData = {
requestBody: TurnBody;
};
export type AiChatResponse = (unknown);
export type AiClearChatResponse = (Array<ChatItem>);
export type AlertsReadAlertsConfigResponse = (AlertsConfig);
export type AlertsSaveAlertsConfigData = {
+307
View File
@@ -0,0 +1,307 @@
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>
)
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useSyncExternalStore } from "react"
/**
* Whether the assistant is open, and whether another panel is open beside it.
*
* Module state rather than context because the chat is mounted once in the
* sidebar and toggled from there, while the editor's own panels report in
* from a different tree: the two only have to agree on who takes which half
* of the lane.
*/
type State = { open: boolean; panels: number }
let state: State = { open: false, panels: 0 }
const listeners = new Set<() => void>()
function set(next: Partial<State>) {
state = { ...state, ...next }
for (const listener of listeners) listener()
}
export const chatStore = {
open: () => set({ open: true }),
close: () => set({ open: false }),
toggle: () => set({ open: !state.open }),
/** A floating panel is open; returns how to say it closed again. */
registerPanel: () => {
set({ panels: state.panels + 1 })
return () => set({ panels: state.panels - 1 })
},
}
export function useChat(): State {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => state,
() => state,
)
}
+78
View File
@@ -0,0 +1,78 @@
import { OpenAPI } from "@/client"
import { apiToken } from "@/lib/portal"
export type ChatEvent =
| { type: "start" }
| { type: "message"; content: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; ok: boolean; error?: string }
| { type: "error"; detail: string }
| { type: "done" }
/**
* One turn, as the server-sent events the API streams while the model works.
*
* A plain fetch rather than the generated client, which has no notion of a
* body that keeps arriving. Every outcome is an event, including a request
* that never got as far as the stream, so the caller has one channel to read.
*/
export async function streamTurn({
message,
flow,
signal,
onEvent,
}: {
message: string
flow?: string
signal: AbortSignal
onEvent: (event: ChatEvent) => void
}): Promise<void> {
let response: Response
try {
response = await fetch(`${OpenAPI.BASE}/api/v1/ai/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiToken()}`,
},
body: JSON.stringify({ message, flow: flow ?? null }),
signal,
})
} catch (error) {
if (!signal.aborted) onEvent({ type: "error", detail: String(error) })
return
}
if (!response.ok || !response.body) {
let detail = `The request failed (${response.status})`
try {
const body = (await response.json()) as { detail?: string }
if (body.detail) detail = body.detail
} catch {
// Not JSON; the status is the message.
}
onEvent({ type: "error", detail })
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let end = buffer.indexOf("\n\n")
while (end !== -1) {
const frame = buffer.slice(0, end).trim()
buffer = buffer.slice(end + 2)
if (frame.startsWith("data:")) {
onEvent(JSON.parse(frame.slice(5)) as ChatEvent)
}
end = buffer.indexOf("\n\n")
}
}
} catch {
// Stopped, or the connection dropped: the turn is over either way.
}
}
+28 -3
View File
@@ -2,7 +2,7 @@ import { Info, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect, useState } from "react"
import { chatStore, useChat } from "@/components/Chat/chatStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -180,6 +180,8 @@ export function PanelTitle({
* for that.
*
* Node settings and flow settings share it, so the two read as one surface.
* The assistant shares it too, and the lane: while it is open, a settings
* panel keeps the top half and the chat takes the bottom.
*/
export function SidePanel({
open,
@@ -187,6 +189,8 @@ export function SidePanel({
testId,
bodyKey,
expanded = false,
fixed = false,
stacked,
header,
footer,
children,
@@ -200,12 +204,26 @@ export function SidePanel({
bodyKey: string
/** Take the width a code editor needs, still floating over the canvas. */
expanded?: boolean
/** Pinned to the viewport rather than the canvas: a panel on any screen. */
fixed?: boolean
/**
* Which half of the lane to keep. Left unset, the panel counts itself as
* open and keeps the top whenever the assistant is open beside it.
*/
stacked?: "top" | "bottom" | false
header: ReactNode
footer?: ReactNode
children: ReactNode
onClose: () => void
}) {
const isMobile = useIsMobile()
const chat = useChat()
const shares = stacked === undefined
useEffect(() => {
if (!shares || !open || isMobile) return
return chatStore.registerPanel()
}, [shares, open, isMobile])
const half = shares ? (chat.open ? "top" : false) : stacked
useEffect(() => {
if (!open) return
@@ -284,12 +302,19 @@ export function SidePanel({
data-testid={testId}
data-expanded={expanded || undefined}
className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
"pointer-events-auto z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
fixed ? "fixed" : "absolute",
expanded
? // Still a floating surface, given the whole inset: the toolbar
// and the flow-name box translate off screen while it is open.
"inset-4"
: "inset-y-4 right-4 w-[400px]",
: "right-4 w-[400px]",
!expanded &&
(half === "top"
? "top-4 bottom-[calc(50%+0.5rem)]"
: half === "bottom"
? "top-[calc(50%+0.5rem)] bottom-4"
: "inset-y-4"),
)}
>
{contents}
+17 -2
View File
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import {
ArrowLeft,
Bell,
BookOpen,
Bot,
FlaskConical,
Home,
KeyRound,
@@ -16,6 +18,8 @@ import {
} from "lucide-react"
import { useState } from "react"
import { aiStatusQueryOptions, ChatPanel } from "@/components/Chat/ChatPanel"
import { chatStore } from "@/components/Chat/chatStore"
import { GlobalSearch } from "@/components/Common/GlobalSearch"
import { Logo } from "@/components/Common/Logo"
import {
@@ -97,10 +101,17 @@ export function AppSidebar() {
onClick: () => setSearchOpen(true),
}
// Only once an operator has set a provider up: with none there is nothing
// to open, and nothing on screen says the feature exists.
const assistant = useQuery(aiStatusQueryOptions())
const tools: Item[] = assistant.data?.available
? [{ icon: Bot, title: "Assistant", onClick: chatStore.toggle }, search]
: [search]
const footerItems: Item[] = portal
? [search, docs, { icon: Settings, title: "Settings", path: "/settings" }]
? [...tools, docs, { icon: Settings, title: "Settings", path: "/settings" }]
: [
search,
...tools,
docs,
{ icon: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout },
@@ -140,6 +151,10 @@ export function AppSidebar() {
{/* Outside the sidebar: on a phone that is a sheet, and a dialog is not
one of its children. */}
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
{/* Mounted once, here, for the same reason: both shells carry the
sidebar, and the chat follows whoever opened it from screen to
screen. */}
<ChatPanel />
</>
)
}
@@ -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
+7
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { motion } from "motion/react"
import Appearance from "@/components/UserSettings/Appearance"
import Assistant from "@/components/UserSettings/Assistant"
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
import UserInformation from "@/components/UserSettings/UserInformation"
import useAuth from "@/hooks/useAuth"
@@ -57,6 +58,12 @@ function UserSettings() {
<RemoteAccess />
</motion.div>
)}
{/* One provider for everyone here, so the same rule applies. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
<Assistant />
</motion.div>
)}
</motion.div>
</div>
)