diff --git a/docs/api/rest.md b/docs/api/rest.md index 2ca5d8a..c384904 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -42,6 +42,23 @@ Chat upload limits: - `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id. - The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message. +## Chat Tools + +### `GET /v1/chat-tools` +- Response: +```json +{ + "tools": [ + { "name": "web_search", "description": "..." }, + { "name": "fetch_url", "description": "..." } + ] +} +``` + +Behavior notes: +- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions. +- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration. + ## Active Runs ### `GET /v1/active-runs` @@ -77,7 +94,9 @@ Behavior notes: "initiatedProvider": "openai", "initiatedModel": "gpt-4.1-mini", "lastUsedProvider": "openai", - "lastUsedModel": "gpt-4.1-mini" + "lastUsedModel": "gpt-4.1-mini", + "additionalSystemPrompt": null, + "enabledTools": ["web_search", "fetch_url"] }, { "type": "search", @@ -111,6 +130,8 @@ Behavior notes: "title": "optional title", "provider": "optional openai|anthropic|xai|hermes-agent", "model": "optional model id", + "additionalSystemPrompt": "optional stored system prompt", + "enabledTools": ["web_search", "fetch_url"], "messages": [ { "role": "system|user|assistant|tool", @@ -126,13 +147,17 @@ Behavior notes: Behavior notes: - `provider` and `model` must be supplied together when present. - When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`. +- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`. +- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools. - Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages. ### `PATCH /v1/chats/:chatId` -- Body: `{ "title": string }` +- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }` - Response: `{ "chat": ChatSummary }` - Blank titles are rejected. The server trims surrounding whitespace before storing the title. -- Renaming updates the returned chat's `updatedAt`. +- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`. +- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged. +- Updating chat fields changes the returned chat's `updatedAt`. - Not found: `404 { "message": "chat not found" }` ### `PATCH /v1/chats/:chatId/star` @@ -237,6 +262,8 @@ Notes: ] } ], + "additionalSystemPrompt": "optional one-off system prompt", + "enabledTools": ["web_search", "fetch_url"], "temperature": 0.2, "maxTokens": 256 } @@ -256,6 +283,8 @@ Notes: Behavior notes: - If `chatId` is present, server validates chat existence. - For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates. +- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint. +- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools. - Server persists final assistant output and call metadata (`LlmCall`) in DB. - Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset. - Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`. @@ -390,7 +419,9 @@ Behavior notes: "initiatedProvider": "openai|anthropic|xai|hermes-agent|null", "initiatedModel": "string|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", - "lastUsedModel": "string|null" + "lastUsedModel": "string|null", + "additionalSystemPrompt": null, + "enabledTools": ["web_search", "fetch_url"] } ``` @@ -441,6 +472,8 @@ Behavior notes: "initiatedModel": "string|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", "lastUsedModel": "string|null", + "additionalSystemPrompt": null, + "enabledTools": ["web_search", "fetch_url"], "messages": [Message] } ``` diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index e877212..7a21061 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -49,6 +49,8 @@ Authentication: ] } ], + "additionalSystemPrompt": "optional one-off system prompt", + "enabledTools": ["web_search", "fetch_url"], "temperature": 0.2, "maxTokens": 256 } @@ -60,6 +62,8 @@ Notes: - If `chatId` is provided, backend validates it exists. - If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata. - For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates. +- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint. +- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools. - Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`. Persisted chat streams with a `chatId` are backend-owned active runs: diff --git a/server/prisma/migrations/20260524000000_add_chat_settings/migration.sql b/server/prisma/migrations/20260524000000_add_chat_settings/migration.sql new file mode 100644 index 0000000..d6e7b74 --- /dev/null +++ b/server/prisma/migrations/20260524000000_add_chat_settings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT; +ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index c435fa9..beead72 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -57,6 +57,9 @@ model Chat { lastUsedProvider Provider? lastUsedModel String? + additionalSystemPrompt String? + enabledTools Json? + user User? @relation(fields: [userId], references: [id]) userId String? diff --git a/server/src/llm/chat-tools.ts b/server/src/llm/chat-tools.ts index f58fd4b..29ed23a 100644 --- a/server/src/llm/chat-tools.ts +++ b/server/src/llm/chat-tools.ts @@ -192,7 +192,43 @@ const CHAT_TOOLS: any[] = [ ...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []), ]; -const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => { +function getToolName(tool: any) { + return typeof tool?.function?.name === "string" ? tool.function.name : null; +} + +export function getAvailableChatTools() { + return CHAT_TOOLS.map((tool) => { + const name = getToolName(tool); + if (!name) return null; + return { + name, + description: typeof tool?.function?.description === "string" ? tool.function.description : "", + }; + }).filter((tool): tool is { name: string; description: string } => tool !== null); +} + +export function normalizeEnabledChatTools(value: unknown) { + if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name); + const available = new Set(getAvailableChatTools().map((tool) => tool.name)); + return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) => + available.has(name) + ); +} + +function getEnabledToolSet(params: Pick) { + return new Set(normalizeEnabledChatTools(params.enabledTools)); +} + +function getEnabledChatTools(params: Pick) { + const enabled = getEnabledToolSet(params); + return CHAT_TOOLS.filter((tool) => { + const name = getToolName(tool); + return name ? enabled.has(name) : false; + }); +} + +function toResponsesChatTools(tools: any[]) { + return tools.map((tool) => { if (tool?.type !== "function") return tool; return { type: "function", @@ -201,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => { parameters: tool.function.parameters, strict: false, }; -}); + }); +} export const CHAT_TOOL_SYSTEM_PROMPT = "You can use tools to gather up-to-date web information when needed. " + @@ -243,6 +280,7 @@ type ToolAwareCompletionParams = { client: OpenAI; model: string; messages: ChatMessage[]; + enabledTools?: string[]; userLocation?: string; temperature?: number; maxTokens?: number; @@ -384,20 +422,38 @@ function extractHtmlTitle(html: string) { ); } -function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string) { +function buildChatToolSystemPrompt(params: Pick) { + const enabled = getEnabledToolSet(params); + return ( + "You can use tools to gather up-to-date web information when needed. " + + (enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") + + (enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") + + "Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " + + "When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " + + (enabled.has("codex_exec") + ? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. " + : "") + + (enabled.has("shell_exec") + ? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. " + : "") + + "Do not fabricate tool outputs; reason only from provided tool results." + ); +} + +function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick = {}) { const normalized = messages.map((message) => buildOpenAIConversationMessage(message)); - return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, buildSystemPromptAugmentationMessage(userLocation), ...normalized]; + return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized]; } function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) { return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))]; } -function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string) { +function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick = {}) { const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message)); - return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, buildSystemPromptAugmentationMessage(userLocation), ...normalized]; + return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized]; } async function runExaWebSearchTool(args: WebSearchArgs): Promise { @@ -962,7 +1018,8 @@ async function executeToolCallAndBuildEvent( } export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise { - const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation); + const enabledTools = getEnabledChatTools(params); + const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; @@ -976,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): input, temperature: params.temperature, max_output_tokens: params.maxTokens, - tools: RESPONSES_CHAT_TOOLS, + tools: toResponsesChatTools(enabledTools), tool_choice: "auto", parallel_tool_calls: true, // Tool loops pass response output items back as input; reasoning items need persistence. @@ -1031,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): } export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise { - const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation); + const enabledTools = getEnabledChatTools(params); + const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; @@ -1045,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar messages: conversation, temperature: params.temperature, max_tokens: params.maxTokens, - tools: CHAT_TOOLS, + tools: enabledTools, tool_choice: "auto", } as any); rawResponses.push(completion); @@ -1139,7 +1197,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams) export async function* runToolAwareOpenAIChatStream( params: ToolAwareCompletionParams ): AsyncGenerator { - const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation); + const enabledTools = getEnabledChatTools(params); + const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; @@ -1153,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream( input, temperature: params.temperature, max_output_tokens: params.maxTokens, - tools: RESPONSES_CHAT_TOOLS, + tools: toResponsesChatTools(enabledTools), tool_choice: "auto", parallel_tool_calls: true, // Tool loops pass response output items back as input; reasoning items need persistence. @@ -1265,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream( export async function* runToolAwareChatCompletionsStream( params: ToolAwareCompletionParams ): AsyncGenerator { - const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation); + const enabledTools = getEnabledChatTools(params); + const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params); const rawResponses: unknown[] = []; const toolEvents: ToolExecutionEvent[] = []; const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; @@ -1279,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream( messages: conversation, temperature: params.temperature, max_tokens: params.maxTokens, - tools: CHAT_TOOLS, + tools: enabledTools, tool_choice: "auto", stream: true, stream_options: { include_usage: true }, diff --git a/server/src/llm/multiplexer.ts b/server/src/llm/multiplexer.ts index f852f13..1bacdb2 100644 --- a/server/src/llm/multiplexer.ts +++ b/server/src/llm/multiplexer.ts @@ -1,7 +1,7 @@ import { performance } from "node:perf_hooks"; import { prisma } from "../db.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js"; -import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js"; +import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js"; import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import { toPrismaProvider } from "./provider-ids.js"; import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js"; @@ -47,13 +47,15 @@ export async function runMultiplex(req: MultiplexRequest): Promise[] = []; + const enabledTools = normalizeEnabledChatTools(req.enabledTools); - if (req.provider === "openai") { + if (req.provider === "openai" && enabledTools.length > 0) { const client = openaiClient(); const r = await runToolAwareOpenAIChat({ client, model: req.model, messages: req.messages, + enabledTools, userLocation: req.userLocation, temperature: req.temperature, maxTokens: req.maxTokens, @@ -67,12 +69,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise buildToolLogMessageData(call.chatId, event)); - } else if (req.provider === "xai") { + } else if (req.provider === "xai" && enabledTools.length > 0) { const client = xaiClient(); const r = await runToolAwareChatCompletions({ client, model: req.model, messages: req.messages, + enabledTools, userLocation: req.userLocation, temperature: req.temperature, maxTokens: req.maxTokens, @@ -86,8 +89,8 @@ export async function runMultiplex(req: MultiplexRequest): Promise buildToolLogMessageData(call.chatId, event)); - } else if (req.provider === "hermes-agent") { - const client = hermesAgentClient(); + } else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") { + const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient(); const r = await runPlainChatCompletions({ client, model: req.model, diff --git a/server/src/llm/streaming.ts b/server/src/llm/streaming.ts index 32ea834..4457a63 100644 --- a/server/src/llm/streaming.ts +++ b/server/src/llm/streaming.ts @@ -3,6 +3,7 @@ import { prisma } from "../db.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js"; import { buildToolLogMessageData, + normalizeEnabledChatTools, runPlainChatCompletionsStream, runToolAwareChatCompletionsStream, runToolAwareOpenAIChatStream, @@ -76,12 +77,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator try { if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") { const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient(); + const enabledTools = normalizeEnabledChatTools(req.enabledTools); const streamEvents = - req.provider === "openai" + req.provider === "openai" && enabledTools.length > 0 ? runToolAwareOpenAIChatStream({ client, model: req.model, messages: req.messages, + enabledTools, userLocation: req.userLocation, temperature: req.temperature, maxTokens: req.maxTokens, @@ -91,7 +94,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator chatId: chatId ?? undefined, }, }) - : req.provider === "hermes-agent" + : req.provider === "hermes-agent" || enabledTools.length === 0 ? runPlainChatCompletionsStream({ client, model: req.model, @@ -109,6 +112,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator client, model: req.model, messages: req.messages, + enabledTools, userLocation: req.userLocation, temperature: req.temperature, maxTokens: req.maxTokens, diff --git a/server/src/llm/types.ts b/server/src/llm/types.ts index 427f053..b4cf6d4 100644 --- a/server/src/llm/types.ts +++ b/server/src/llm/types.ts @@ -36,6 +36,8 @@ export type MultiplexRequest = { provider: Provider; model: string; messages: ChatMessage[]; + additionalSystemPrompt?: string; + enabledTools?: string[]; userLocation?: string; temperature?: number; maxTokens?: number; diff --git a/server/src/routes.ts b/server/src/routes.ts index 681f7fe..09fbd6b 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -8,6 +8,7 @@ import { env } from "./env.js"; import { buildComparableAttachments } from "./llm/message-content.js"; import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js"; +import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js"; import { getModelCatalogSnapshot } from "./llm/model-catalog.js"; import { openaiClient } from "./llm/providers.js"; import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js"; @@ -16,6 +17,8 @@ import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; import type { ChatAttachment } from "./llm/types.js"; const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]); +const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000; +const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value)); type IncomingChatMessage = { role: "system" | "user" | "assistant" | "tool"; @@ -169,6 +172,8 @@ const CompletionStreamBody = z provider: ProviderSchema, model: z.string().min(1), messages: z.array(CompletionMessageSchema), + additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(), + enabledTools: EnabledToolsSchema.optional(), userLocation: z.string().trim().min(1).max(200).optional(), temperature: z.number().min(0).max(2).optional(), maxTokens: z.number().int().positive().optional(), @@ -194,6 +199,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac }; } +function normalizeAdditionalSystemPrompt(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed || null; +} + +function prependAdditionalSystemPrompt(body: T): T { + const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt); + if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined }; + return { + ...body, + additionalSystemPrompt, + messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages], + }; +} + +async function applyStoredChatSettings( + body: T +) { + if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) { + return prependAdditionalSystemPrompt(body); + } + + const chat = await prisma.chat.findUnique({ + where: { id: body.chatId }, + select: { additionalSystemPrompt: true, enabledTools: true }, + }); + if (!chat) return prependAdditionalSystemPrompt(body); + + return prependAdditionalSystemPrompt({ + ...body, + additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined, + enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools), + }); +} + const SearchRunBody = z.object({ query: z.string().trim().min(1).optional(), title: z.string().trim().min(1).optional(), @@ -377,6 +417,8 @@ const chatSummarySelect = { initiatedModel: true, lastUsedProvider: true, lastUsedModel: true, + additionalSystemPrompt: true, + enabledTools: true, projectItems: starredProjectItemsSelect, } as const; @@ -754,6 +796,11 @@ export async function registerRoutes(app: FastifyInstance) { return { providers: getModelCatalogSnapshot() }; }); + app.get("/v1/chat-tools", async (req) => { + requireAdmin(req); + return { tools: getAvailableChatTools() }; + }); + app.get("/v1/active-runs", async (req) => { requireAdmin(req); return { @@ -784,6 +831,8 @@ export async function registerRoutes(app: FastifyInstance) { title: z.string().optional(), provider: ProviderSchema.optional(), model: z.string().trim().min(1).optional(), + additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(), + enabledTools: EnabledToolsSchema.optional(), messages: z.array(CompletionMessageSchema).optional(), }) .superRefine((value, ctx) => { @@ -812,6 +861,8 @@ export async function registerRoutes(app: FastifyInstance) { initiatedModel: body.model, lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined, lastUsedModel: body.model, + additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt), + enabledTools: body.enabledTools as any, messages: body.messages?.length ? { create: body.messages.map((message) => ({ @@ -831,13 +882,22 @@ export async function registerRoutes(app: FastifyInstance) { app.patch("/v1/chats/:chatId", async (req) => { requireAdmin(req); const Params = z.object({ chatId: z.string() }); - const Body = z.object({ title: z.string().trim().min(1) }); + const Body = z.object({ + title: z.string().trim().min(1).optional(), + additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(), + enabledTools: EnabledToolsSchema.optional(), + }); const { chatId } = Params.parse(req.params); const body = Body.parse(req.body ?? {}); + const data: Record = {}; + if (body.title !== undefined) data.title = body.title; + if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt); + if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools; + const updated = await prisma.chat.updateMany({ where: { id: chatId }, - data: { title: body.title }, + data: data as any, }); if (updated.count === 0) return app.httpErrors.notFound("chat not found"); @@ -1249,6 +1309,8 @@ export async function registerRoutes(app: FastifyInstance) { provider: ProviderSchema, model: z.string().min(1), messages: z.array(CompletionMessageSchema), + additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(), + enabledTools: EnabledToolsSchema.optional(), userLocation: z.string().trim().min(1).max(200).optional(), temperature: z.number().min(0).max(2).optional(), maxTokens: z.number().int().positive().optional(), @@ -1269,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) { await storeNonAssistantMessages(body.chatId, body.messages); } - const result = await runMultiplex(body); + const result = await runMultiplex(await applyStoredChatSettings(body)); return { chatId: body.chatId ?? null, @@ -1300,14 +1362,14 @@ export async function registerRoutes(app: FastifyInstance) { if (activeChatStreams.has(body.chatId)) { return app.httpErrors.conflict("chat completion already running"); } - const stream = startActiveChatStream(body.chatId, body); + const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body)); return streamActiveRun(req, reply, stream); } reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.flushHeaders(); - for await (const ev of runMultiplexStream(body)) { + for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) { writeSseEvent(reply, mapChatStreamEvent(ev)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 3a9d5fa..82ed546 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,22 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact"; +import { + Check, + ChevronDown, + Globe2, + LoaderCircle, + Menu, + MessageSquare, + Paperclip, + Pencil, + Plus, + Rabbit, + Search, + SendHorizontal, + Settings2, + Star, + Trash2, + X, +} from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; @@ -18,6 +35,7 @@ import { attachSearchStream, getActiveRuns, getChat, + listChatTools, listModels, getSearch, listWorkspaceItems, @@ -27,6 +45,7 @@ import { updateChatTitle, updateChatStar, updateSearchStar, + updateChatSettings, getMessageAttachments, type ChatAttachment, type ActiveRunsResponse, @@ -34,6 +53,7 @@ import { type Provider, type ChatDetail, type ChatSummary, + type ChatToolInfo, type CompletionRequestMessage, type Message, type SearchDetail, @@ -379,6 +399,30 @@ function getProviderLabel(provider: Provider | null | undefined) { return ""; } +function getToolLabel(name: string) { + if (name === "web_search") return "Web search"; + if (name === "fetch_url") return "Fetch URL"; + if (name === "codex_exec") return "Codex"; + if (name === "shell_exec") return "Shell"; + return name + .split("_") + .filter(Boolean) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); +} + +function getDefaultEnabledTools(availableTools: ChatToolInfo[]) { + return availableTools.map((tool) => tool.name); +} + +function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) { + const available = new Set(availableTools.map((tool) => tool.name)); + if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools); + return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) => + available.has(name) + ); +} + function getChatModelSelection(chat: Pick | Pick | null) { if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; return { @@ -748,6 +792,7 @@ export default function App() { const [isComposerDropActive, setIsComposerDropActive] = useState(false); const [provider, setProvider] = useState("openai"); const [modelCatalog, setModelCatalog] = useState(EMPTY_MODEL_CATALOG); + const [availableChatTools, setAvailableChatTools] = useState([]); const [providerModelPreferences, setProviderModelPreferences] = useState(() => loadStoredModelPreferences()); const [model, setModel] = useState(() => { const stored = loadStoredModelPreferences(); @@ -774,6 +819,9 @@ export default function App() { const [renameChatDraft, setRenameChatDraft] = useState(""); const [renameChatError, setRenameChatError] = useState(null); const [isRenamingChat, setIsRenamingChat] = useState(false); + const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false); + const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState(""); + const [enabledTools, setEnabledTools] = useState([]); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const transcriptContainerRef = useRef(null); const transcriptEndRef = useRef(null); @@ -899,6 +947,9 @@ export default function App() { searchRunCountersRef.current.clear(); setComposer(""); setPendingAttachments([]); + setIsChatSettingsOpen(false); + setAdditionalSystemPrompt(""); + setEnabledTools([]); setIsQuickQuestionOpen(false); setQuickPrompt(""); setQuickSubmittedPrompt(null); @@ -968,6 +1019,21 @@ export default function App() { } }; + const refreshChatTools = async () => { + try { + const tools = await listChatTools(); + setAvailableChatTools(tools); + setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setError(message); + } + } + }; + const refreshActiveRuns = async () => { try { const data = await getActiveRuns(); @@ -1020,7 +1086,7 @@ export default function App() { if (!isAuthenticated) return; const preferredSelection = initialRouteSelectionRef.current; initialRouteSelectionRef.current = null; - void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]); + void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]); }, [isAuthenticated]); useEffect(() => { @@ -1287,6 +1353,19 @@ export default function App() { setModel(nextSelection.model); }, [draftKind, selectedChat, selectedChatSummary, selectedItem]); + useEffect(() => { + if (draftKind === "chat") { + setAdditionalSystemPrompt(""); + setEnabledTools(getDefaultEnabledTools(availableChatTools)); + return; + } + if (selectedItem?.kind !== "chat") return; + const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary; + if (!chat) return; + setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? ""); + setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools)); + }, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]); + const selectedTitle = useMemo(() => { if (draftKind === "chat") return "New chat"; if (draftKind === "search") return "New search"; @@ -1441,6 +1520,8 @@ export default function App() { initiatedModel: updatedChat.initiatedModel, lastUsedProvider: updatedChat.lastUsedProvider, lastUsedModel: updatedChat.lastUsedModel, + additionalSystemPrompt: updatedChat.additionalSystemPrompt, + enabledTools: updatedChat.enabledTools, }; }); }; @@ -1768,6 +1849,8 @@ export default function App() { initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, + additionalSystemPrompt: chat.additionalSystemPrompt, + enabledTools: chat.enabledTools, messages: [], }); setSelectedSearch(null); @@ -2349,6 +2432,8 @@ export default function App() { initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, + additionalSystemPrompt: chat.additionalSystemPrompt, + enabledTools: chat.enabledTools, messages: [], }); setSelectedSearch(null); @@ -2527,6 +2612,8 @@ export default function App() { initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, + additionalSystemPrompt: chat.additionalSystemPrompt, + enabledTools: chat.enabledTools, messages: [], }); setSelectedSearch(null); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4c80590..43e372e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,6 +9,8 @@ export type ChatSummary = { initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; + additionalSystemPrompt: string | null; + enabledTools: string[] | null; }; export type SearchSummary = { @@ -64,6 +66,8 @@ export type ChatDetail = { initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; + additionalSystemPrompt: string | null; + enabledTools: string[] | null; messages: Message[]; }; @@ -157,6 +161,11 @@ export type ModelCatalogResponse = { providers: Partial>; }; +export type ChatToolInfo = { + name: string; + description: string; +}; + export type ActiveRunsResponse = { chats: string[]; searches: string[]; @@ -182,6 +191,8 @@ type CreateChatRequest = { title?: string; provider?: Provider; model?: string; + additionalSystemPrompt?: string; + enabledTools?: string[]; messages?: CompletionRequestMessage[]; }; @@ -257,6 +268,11 @@ export async function listModels() { return api("/v1/models"); } +export async function listChatTools() { + const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools"); + return data.tools; +} + export async function getActiveRuns() { return api("/v1/active-runs"); } @@ -291,6 +307,14 @@ export async function updateChatStar(chatId: string, starred: boolean) { return data.chat; } +export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) { + const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + return data.chat; +} + export async function suggestChatTitle(body: { chatId: string; content: string }) { const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", { method: "POST", @@ -613,6 +637,8 @@ export async function runCompletion(body: { provider: Provider; model: string; messages: CompletionRequestMessage[]; + additionalSystemPrompt?: string; + enabledTools?: string[]; userLocation?: string; }) { return api("/v1/chat-completions", { @@ -628,6 +654,8 @@ export async function runCompletionStream( provider: Provider; model: string; messages: CompletionRequestMessage[]; + additionalSystemPrompt?: string; + enabledTools?: string[]; userLocation?: string; }, handlers: CompletionStreamHandlers,