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:
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user