4 Commits

15 changed files with 816 additions and 112 deletions

View File

@@ -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. - `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. - 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 ## Active Runs
### `GET /v1/active-runs` ### `GET /v1/active-runs`
@@ -77,7 +94,9 @@ Behavior notes:
"initiatedProvider": "openai", "initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini", "initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai", "lastUsedProvider": "openai",
"lastUsedModel": "gpt-4.1-mini" "lastUsedModel": "gpt-4.1-mini",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
}, },
{ {
"type": "search", "type": "search",
@@ -111,6 +130,8 @@ Behavior notes:
"title": "optional title", "title": "optional title",
"provider": "optional openai|anthropic|xai|hermes-agent", "provider": "optional openai|anthropic|xai|hermes-agent",
"model": "optional model id", "model": "optional model id",
"additionalSystemPrompt": "optional stored system prompt",
"enabledTools": ["web_search", "fetch_url"],
"messages": [ "messages": [
{ {
"role": "system|user|assistant|tool", "role": "system|user|assistant|tool",
@@ -126,13 +147,17 @@ Behavior notes:
Behavior notes: Behavior notes:
- `provider` and `model` must be supplied together when present. - `provider` and `model` must be supplied together when present.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`. - 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. - Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `PATCH /v1/chats/:chatId` ### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }` - Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
- Response: `{ "chat": ChatSummary }` - Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title. - 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" }` - Not found: `404 { "message": "chat not found" }`
### `PATCH /v1/chats/:chatId/star` ### `PATCH /v1/chats/:chatId/star`
@@ -237,6 +262,8 @@ Notes:
] ]
} }
], ],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2, "temperature": 0.2,
"maxTokens": 256 "maxTokens": 256
} }
@@ -256,6 +283,8 @@ Notes:
Behavior notes: Behavior notes:
- If `chatId` is present, server validates chat existence. - If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates. - 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 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. - 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`. - 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", "initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|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", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null", "lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"],
"messages": [Message] "messages": [Message]
} }
``` ```

View File

@@ -49,6 +49,8 @@ Authentication:
] ]
} }
], ],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2, "temperature": 0.2,
"maxTokens": 256 "maxTokens": 256
} }
@@ -60,6 +62,8 @@ Notes:
- If `chatId` is provided, backend validates it exists. - 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. - 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. - 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`. - 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: Persisted chat streams with a `chatId` are backend-owned active runs:

View File

@@ -661,6 +661,7 @@ struct CompletionStreamRequest: Codable, Sendable {
var provider: Provider var provider: Provider
var model: String var model: String
var messages: [CompletionRequestMessage] var messages: [CompletionRequestMessage]
var userLocation: String? = nil
} }
private struct ChatCreateBody: Encodable { private struct ChatCreateBody: Encodable {

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;

View File

@@ -57,6 +57,9 @@ model Chat {
lastUsedProvider Provider? lastUsedProvider Provider?
lastUsedModel String? lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?

View File

@@ -9,7 +9,11 @@ import { z } from "zod";
import { env } from "../env.js"; import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js"; import {
buildOpenAIConversationMessage,
buildOpenAIResponsesInputMessage,
buildSystemPromptAugmentationMessage,
} from "./message-content.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS; const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
@@ -188,7 +192,43 @@ const CHAT_TOOLS: any[] = [
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []), ...(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<ToolAwareCompletionParams, "enabledTools">) {
return new Set(normalizeEnabledChatTools(params.enabledTools));
}
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
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; if (tool?.type !== "function") return tool;
return { return {
type: "function", type: "function",
@@ -197,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
parameters: tool.function.parameters, parameters: tool.function.parameters,
strict: false, strict: false,
}; };
}); });
}
export const CHAT_TOOL_SYSTEM_PROMPT = export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " + "You can use tools to gather up-to-date web information when needed. " +
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
client: OpenAI; client: OpenAI;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>; onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
@@ -379,20 +422,38 @@ function extractHtmlTitle(html: string) {
); );
} }
function normalizeIncomingMessages(messages: ChatMessage[]) { function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
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<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message)); const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
function normalizePlainIncomingMessages(messages: ChatMessage[]) { function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
return messages.map((message) => buildOpenAIConversationMessage(message)); return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
} }
function normalizeIncomingResponsesInput(messages: ChatMessage[]) { function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message)); const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> { async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
@@ -957,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
} }
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -971,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1026,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
} }
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1040,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
} as any); } as any);
rawResponses.push(completion); rawResponses.push(completion);
@@ -1114,7 +1177,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const completion = await params.client.chat.completions.create({ const completion = await params.client.chat.completions.create({
model: params.model, model: params.model,
messages: normalizePlainIncomingMessages(params.messages), messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
} as any); } as any);
@@ -1134,7 +1197,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
export async function* runToolAwareOpenAIChatStream( export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1148,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1260,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
export async function* runToolAwareChatCompletionsStream( export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1274,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
@@ -1403,7 +1468,7 @@ export async function* runPlainChatCompletionsStream(
const stream = await params.client.chat.completions.create({ const stream = await params.client.chat.completions.create({
model: params.model, model: params.model,
messages: normalizePlainIncomingMessages(params.messages), messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
stream: true, stream: true,

View File

@@ -1,5 +1,19 @@
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js"; import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
const DEFAULT_USER_LOCATION = "San Francisco, CA";
function currentDateString(now = new Date()) {
return now.toISOString().slice(0, 10);
}
function resolveUserLocation(userLocation?: string) {
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
}
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
}
function escapeAttribute(value: string) { function escapeAttribute(value: string) {
return value.replace(/"/g, "&quot;"); return value.replace(/"/g, "&quot;");
} }
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
}; };
} }
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
return {
role: "system",
content: buildSystemPromptAugmentation(userLocation),
};
}
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT = const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat."; "This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
export function getAnthropicSystemPrompt(messages: ChatMessage[]) { export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content] return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
} }

View File

@@ -1,7 +1,7 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.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 { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js"; import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js"; import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let usage: MultiplexResponse["usage"] | undefined; let usage: MultiplexResponse["usage"] | undefined;
let raw: unknown; let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = []; let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
if (req.provider === "openai") { if (req.provider === "openai" && enabledTools.length > 0) {
const client = openaiClient(); const client = openaiClient();
const r = await runToolAwareOpenAIChat({ const r = await runToolAwareOpenAIChat({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "xai") { } else if (req.provider === "xai" && enabledTools.length > 0) {
const client = xaiClient(); const client = xaiClient();
const r = await runToolAwareChatCompletions({ const r = await runToolAwareChatCompletions({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -84,12 +89,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "hermes-agent") { } else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = hermesAgentClient(); const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const r = await runPlainChatCompletions({ const r = await runPlainChatCompletions({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -104,7 +110,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const r = await client.messages.create({ const r = await client.messages.create({

View File

@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { import {
buildToolLogMessageData, buildToolLogMessageData,
normalizeEnabledChatTools,
runPlainChatCompletionsStream, runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
@@ -76,12 +77,15 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
try { try {
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") { if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient(); const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
const streamEvents = const streamEvents =
req.provider === "openai" req.provider === "openai" && enabledTools.length > 0
? runToolAwareOpenAIChatStream({ ? runToolAwareOpenAIChatStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -90,11 +94,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId: chatId ?? undefined, chatId: chatId ?? undefined,
}, },
}) })
: req.provider === "hermes-agent" : req.provider === "hermes-agent" || enabledTools.length === 0
? runPlainChatCompletionsStream({ ? runPlainChatCompletionsStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -107,6 +112,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -146,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const stream = await client.messages.create({ const stream = await client.messages.create({

View File

@@ -36,6 +36,9 @@ export type MultiplexRequest = {
provider: Provider; provider: Provider;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
}; };

View File

@@ -8,6 +8,7 @@ import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js"; import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.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 { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js"; import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.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"; import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]); 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 = { type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool"; role: "system" | "user" | "assistant" | "tool";
@@ -48,6 +51,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
return message.role === "tool" && isToolCallLogMetadata(message.metadata); return message.role === "tool" && isToolCallLogMetadata(message.metadata);
} }
function getHeaderString(req: FastifyRequest, name: string) {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value.find((item) => item.trim());
return typeof value === "string" && value.trim() ? value : undefined;
}
function decodeHeaderPart(value: string | undefined) {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
function inferRequestUserLocation(req: FastifyRequest) {
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
if (explicit) return explicit;
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
if (vercelLocation) return vercelLocation;
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
}
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) { async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant"); const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return; if (!incoming.length) return;
@@ -132,6 +172,9 @@ const CompletionStreamBody = z
provider: ProviderSchema, provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), 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(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}) })
@@ -156,6 +199,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
}; };
} }
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
}
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(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<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
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({ const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(), query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(), title: z.string().trim().min(1).optional(),
@@ -339,6 +417,8 @@ const chatSummarySelect = {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
projectItems: starredProjectItemsSelect, projectItems: starredProjectItemsSelect,
} as const; } as const;
@@ -716,6 +796,11 @@ export async function registerRoutes(app: FastifyInstance) {
return { providers: getModelCatalogSnapshot() }; return { providers: getModelCatalogSnapshot() };
}); });
app.get("/v1/chat-tools", async (req) => {
requireAdmin(req);
return { tools: getAvailableChatTools() };
});
app.get("/v1/active-runs", async (req) => { app.get("/v1/active-runs", async (req) => {
requireAdmin(req); requireAdmin(req);
return { return {
@@ -746,6 +831,8 @@ export async function registerRoutes(app: FastifyInstance) {
title: z.string().optional(), title: z.string().optional(),
provider: ProviderSchema.optional(), provider: ProviderSchema.optional(),
model: z.string().trim().min(1).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(), messages: z.array(CompletionMessageSchema).optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
@@ -774,6 +861,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: body.model, initiatedModel: body.model,
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined, lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model, lastUsedModel: body.model,
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
enabledTools: body.enabledTools as any,
messages: body.messages?.length messages: body.messages?.length
? { ? {
create: body.messages.map((message) => ({ create: body.messages.map((message) => ({
@@ -793,13 +882,22 @@ export async function registerRoutes(app: FastifyInstance) {
app.patch("/v1/chats/:chatId", async (req) => { app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ chatId: z.string() }); 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 { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {}); const body = Body.parse(req.body ?? {});
const data: Record<string, unknown> = {};
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({ const updated = await prisma.chat.updateMany({
where: { id: chatId }, where: { id: chatId },
data: { title: body.title }, data: data as any,
}); });
if (updated.count === 0) return app.httpErrors.notFound("chat not found"); if (updated.count === 0) return app.httpErrors.notFound("chat not found");
@@ -1211,13 +1309,16 @@ export async function registerRoutes(app: FastifyInstance) {
provider: ProviderSchema, provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), 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(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}); });
const parsed = Body.safeParse(req.body); const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1230,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) {
await storeNonAssistantMessages(body.chatId, body.messages); await storeNonAssistantMessages(body.chatId, body.messages);
} }
const result = await runMultiplex(body); const result = await runMultiplex(await applyStoredChatSettings(body));
return { return {
chatId: body.chatId ?? null, chatId: body.chatId ?? null,
@@ -1244,7 +1345,7 @@ export async function registerRoutes(app: FastifyInstance) {
const parsed = CompletionStreamBody.safeParse(req.body); const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1261,14 +1362,14 @@ export async function registerRoutes(app: FastifyInstance) {
if (activeChatStreams.has(body.chatId)) { if (activeChatStreams.has(body.chatId)) {
return app.httpErrors.conflict("chat completion already running"); 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); return streamActiveRun(req, reply, stream);
} }
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders(); reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(body)) { for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
writeSseEvent(reply, mapChatStreamEvent(ev)); writeSseEvent(reply, mapChatStreamEvent(ev));
} }

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
test("system prompt augmentation includes date and default location", () => {
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
});
test("system prompt augmentation uses provided user location", () => {
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
});
test("Anthropic system prompt includes runtime context with existing system messages", () => {
const prompt = getAnthropicSystemPrompt(
[{ role: "system", content: "Use concise answers." }],
"Los Angeles, CA"
);
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
assert.match(prompt, /User location: Los Angeles, CA\./);
assert.match(prompt, /Use concise answers\./);
});

View File

@@ -124,6 +124,7 @@ export class SybilApiClient {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }

View File

@@ -1,5 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; 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 { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -18,6 +35,7 @@ import {
attachSearchStream, attachSearchStream,
getActiveRuns, getActiveRuns,
getChat, getChat,
listChatTools,
listModels, listModels,
getSearch, getSearch,
listWorkspaceItems, listWorkspaceItems,
@@ -27,6 +45,7 @@ import {
updateChatTitle, updateChatTitle,
updateChatStar, updateChatStar,
updateSearchStar, updateSearchStar,
updateChatSettings,
getMessageAttachments, getMessageAttachments,
type ChatAttachment, type ChatAttachment,
type ActiveRunsResponse, type ActiveRunsResponse,
@@ -34,6 +53,7 @@ import {
type Provider, type Provider,
type ChatDetail, type ChatDetail,
type ChatSummary, type ChatSummary,
type ChatToolInfo,
type CompletionRequestMessage, type CompletionRequestMessage,
type Message, type Message,
type SearchDetail, type SearchDetail,
@@ -379,6 +399,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
return ""; 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<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) { function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return { return {
@@ -748,6 +792,7 @@ export default function App() {
const [isComposerDropActive, setIsComposerDropActive] = useState(false); const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG); const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences()); const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences(); const stored = loadStoredModelPreferences();
@@ -774,6 +819,18 @@ export default function App() {
const [renameChatDraft, setRenameChatDraft] = useState(""); const [renameChatDraft, setRenameChatDraft] = useState("");
const [renameChatError, setRenameChatError] = useState<string | null>(null); const [renameChatError, setRenameChatError] = useState<string | null>(null);
const [isRenamingChat, setIsRenamingChat] = useState(false); const [isRenamingChat, setIsRenamingChat] = useState(false);
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
const [isSavingChatSettings, setIsSavingChatSettings] = useState(false);
const [isTogglingChatSettingsStar, setIsTogglingChatSettingsStar] = useState(false);
const [chatSettingsError, setChatSettingsError] = useState<string | null>(null);
const [draftChatTitle, setDraftChatTitle] = useState("");
const [chatSettingsTitleDraft, setChatSettingsTitleDraft] = useState("");
const [chatSettingsProviderDraft, setChatSettingsProviderDraft] = useState<Provider>("openai");
const [chatSettingsModelDraft, setChatSettingsModelDraft] = useState("");
const [chatSettingsPromptDraft, setChatSettingsPromptDraft] = useState("");
const [chatSettingsEnabledToolsDraft, setChatSettingsEnabledToolsDraft] = useState<string[]>([]);
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
const [enabledTools, setEnabledTools] = useState<string[]>([]);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null); const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
@@ -899,6 +956,18 @@ export default function App() {
searchRunCountersRef.current.clear(); searchRunCountersRef.current.clear();
setComposer(""); setComposer("");
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false);
setIsSavingChatSettings(false);
setIsTogglingChatSettingsStar(false);
setChatSettingsError(null);
setDraftChatTitle("");
setChatSettingsTitleDraft("");
setChatSettingsProviderDraft("openai");
setChatSettingsModelDraft("");
setChatSettingsPromptDraft("");
setChatSettingsEnabledToolsDraft([]);
setAdditionalSystemPrompt("");
setEnabledTools([]);
setIsQuickQuestionOpen(false); setIsQuickQuestionOpen(false);
setQuickPrompt(""); setQuickPrompt("");
setQuickSubmittedPrompt(null); setQuickSubmittedPrompt(null);
@@ -968,6 +1037,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 () => { const refreshActiveRuns = async () => {
try { try {
const data = await getActiveRuns(); const data = await getActiveRuns();
@@ -1020,7 +1104,7 @@ export default function App() {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const preferredSelection = initialRouteSelectionRef.current; const preferredSelection = initialRouteSelectionRef.current;
initialRouteSelectionRef.current = null; initialRouteSelectionRef.current = null;
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]); void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
}, [isAuthenticated]); }, [isAuthenticated]);
useEffect(() => { useEffect(() => {
@@ -1065,6 +1149,10 @@ export default function App() {
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]); const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
const chatSettingsProviderModelOptions = useMemo(
() => getModelOptions(modelCatalog, chatSettingsProviderDraft),
[chatSettingsProviderDraft, modelCatalog]
);
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]); const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
useEffect(() => { useEffect(() => {
@@ -1267,11 +1355,6 @@ export default function App() {
return chats.find((chat) => chat.id === selectedItem.id) ?? null; return chats.find((chat) => chat.id === selectedItem.id) ?? null;
}, [chats, selectedItem]); }, [chats, selectedItem]);
const selectedSidebarItem = useMemo(() => {
if (!selectedItem) return null;
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
}, [selectedItem, sidebarItems]);
const selectedSearchSummary = useMemo(() => { const selectedSearchSummary = useMemo(() => {
if (!selectedItem || selectedItem.kind !== "search") return null; if (!selectedItem || selectedItem.kind !== "search") return null;
return searches.find((search) => search.id === selectedItem.id) ?? null; return searches.find((search) => search.id === selectedItem.id) ?? null;
@@ -1287,8 +1370,17 @@ export default function App() {
setModel(nextSelection.model); setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]); }, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
useEffect(() => {
if (draftKind === "chat") 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(() => { const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat"; if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
if (draftKind === "search") return "New search"; if (draftKind === "search") return "New search";
if (!selectedItem) return "Sybil"; if (!selectedItem) return "Sybil";
if (selectedItem.kind === "chat") { if (selectedItem.kind === "chat") {
@@ -1299,7 +1391,7 @@ export default function App() {
if (selectedSearchForView) return getSearchTitle(selectedSearchForView); if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary); if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
return "New search"; return "New search";
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]); }, [draftChatTitle, draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (draftKind || !selectedItem) return "Sybil"; if (draftKind || !selectedItem) return "Sybil";
@@ -1331,6 +1423,11 @@ export default function App() {
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(null); setSelectedSearch(null);
setPendingAttachments([]); setPendingAttachments([]);
setDraftChatTitle("");
setAdditionalSystemPrompt("");
setEnabledTools(getDefaultEnabledTools(availableChatTools));
setIsChatSettingsOpen(false);
setChatSettingsError(null);
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
}; };
@@ -1348,6 +1445,8 @@ export default function App() {
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(null); setSelectedSearch(null);
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false);
setChatSettingsError(null);
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
}; };
@@ -1441,6 +1540,8 @@ export default function App() {
initiatedModel: updatedChat.initiatedModel, initiatedModel: updatedChat.initiatedModel,
lastUsedProvider: updatedChat.lastUsedProvider, lastUsedProvider: updatedChat.lastUsedProvider,
lastUsedModel: updatedChat.lastUsedModel, lastUsedModel: updatedChat.lastUsedModel,
additionalSystemPrompt: updatedChat.additionalSystemPrompt,
enabledTools: updatedChat.enabledTools,
}; };
}); });
}; };
@@ -1476,6 +1577,99 @@ export default function App() {
setRenameChatDialog({ chatId }); setRenameChatDialog({ chatId });
}; };
const getChatSettingsSeedTitle = () => {
if (draftKind === "chat") return draftChatTitle;
if (selectedItem?.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
}
return draftChatTitle;
};
const openChatSettings = () => {
if (isSearchMode) return;
setContextMenu(null);
setRenameChatDialog(null);
setChatSettingsError(null);
setChatSettingsTitleDraft(getChatSettingsSeedTitle());
setChatSettingsProviderDraft(provider);
setChatSettingsModelDraft(model);
setChatSettingsPromptDraft(additionalSystemPrompt);
setChatSettingsEnabledToolsDraft(normalizeEnabledTools(enabledTools, availableChatTools));
setIsChatSettingsOpen(true);
};
const toggleChatSettingsTool = (toolName: string) => {
setChatSettingsEnabledToolsDraft((current) => {
if (current.includes(toolName)) return current.filter((name) => name !== toolName);
return current.concat(toolName);
});
};
const commitLocalChatSettings = (nextProvider: Provider, nextModel: string, nextPrompt: string, nextTools: string[], nextTitle: string) => {
setProvider(nextProvider);
setModel(nextModel);
setProviderModelPreferences((current) => ({
...current,
[nextProvider]: nextModel || null,
}));
setAdditionalSystemPrompt(nextPrompt);
setEnabledTools(nextTools);
setDraftChatTitle(nextTitle);
};
const handleChatSettingsSubmit = async (event?: Event) => {
event?.preventDefault();
if (isSavingChatSettings) return;
const nextModel = chatSettingsModelDraft.trim();
if (!nextModel) {
setChatSettingsError("Enter a model.");
return;
}
const existingChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
const isExistingChat = existingChatId !== null;
const nextTitle = chatSettingsTitleDraft.trim();
if (isExistingChat && !nextTitle) {
setChatSettingsError("Enter a chat title.");
return;
}
const nextPrompt = chatSettingsPromptDraft.trim();
const nextTools = availableChatTools.length
? normalizeEnabledTools(chatSettingsEnabledToolsDraft, availableChatTools)
: chatSettingsEnabledToolsDraft;
setIsSavingChatSettings(true);
setChatSettingsError(null);
setError(null);
try {
if (isExistingChat) {
const updatedChat = await updateChatSettings(existingChatId, {
title: nextTitle,
additionalSystemPrompt: nextPrompt || null,
...(availableChatTools.length ? { enabledTools: nextTools } : {}),
});
applyChatSummary(updatedChat);
} else if (!selectedItem && draftKind !== "chat") {
setDraftKind("chat");
}
commitLocalChatSettings(chatSettingsProviderDraft, nextModel, nextPrompt, nextTools, nextTitle);
setIsChatSettingsOpen(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setChatSettingsError(message);
}
} finally {
setIsSavingChatSettings(false);
}
};
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault(); event.preventDefault();
const menuWidth = 176; const menuWidth = 176;
@@ -1540,6 +1734,29 @@ export default function App() {
} }
}; };
const handleToggleChatSettingsStar = async () => {
if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return;
const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id);
const nextStarred = !current?.starred;
setIsTogglingChatSettingsStar(true);
setChatSettingsError(null);
setError(null);
try {
const updatedChat = await updateChatStar(selectedItem.id, nextStarred);
applyChatSummary(updatedChat, false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setChatSettingsError(message);
}
} finally {
setIsTogglingChatSettingsStar(false);
}
};
const handleDeleteFromContextMenu = async () => { const handleDeleteFromContextMenu = async () => {
if (!contextMenu || isItemRunning(contextMenu.item)) return; if (!contextMenu || isItemRunning(contextMenu.item)) return;
const target = contextMenu.item; const target = contextMenu.item;
@@ -1588,6 +1805,17 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [renameChatDialog]); }, [renameChatDialog]);
useEffect(() => {
if (!isChatSettingsOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || isSavingChatSettings) return;
event.preventDefault();
setIsChatSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isChatSettingsOpen, isSavingChatSettings]);
useEffect(() => { useEffect(() => {
if (!isQuickQuestionOpen) return; if (!isQuickQuestionOpen) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -1748,9 +1976,17 @@ export default function App() {
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
if (!chatId) { if (!chatId) {
const chat = await createChat(); const initialEnabledTools = availableChatTools.length ? normalizeEnabledTools(enabledTools, availableChatTools) : undefined;
const chat = await createChat({
...(draftChatTitle.trim() ? { title: draftChatTitle.trim() } : {}),
provider,
model: selectedModel,
...(additionalSystemPrompt.trim() ? { additionalSystemPrompt: additionalSystemPrompt.trim() } : {}),
...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}),
});
chatId = chat.id; chatId = chat.id;
setDraftKind(null); setDraftKind(null);
setDraftChatTitle("");
setChats((current) => { setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
@@ -1768,6 +2004,8 @@ export default function App() {
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
additionalSystemPrompt: chat.additionalSystemPrompt,
enabledTools: chat.enabledTools,
messages: [], messages: [],
}); });
setSelectedSearch(null); setSelectedSearch(null);
@@ -2349,6 +2587,8 @@ export default function App() {
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
additionalSystemPrompt: chat.additionalSystemPrompt,
enabledTools: chat.enabledTools,
messages: [], messages: [],
}); });
setSelectedSearch(null); setSelectedSearch(null);
@@ -2527,6 +2767,8 @@ export default function App() {
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
additionalSystemPrompt: chat.additionalSystemPrompt,
enabledTools: chat.enabledTools,
messages: [], messages: [],
}); });
setSelectedSearch(null); setSelectedSearch(null);
@@ -2595,6 +2837,10 @@ export default function App() {
} }
}; };
const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
const chatSettingsStarred = chatSettingsChatId
? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false
: false;
if (isCheckingSession) { if (isCheckingSession) {
return ( return (
@@ -2773,8 +3019,8 @@ export default function App() {
</aside> </aside>
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border"> <main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7"> <header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex items-start gap-2"> <div className="flex min-w-0 items-center gap-2">
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -2788,68 +3034,24 @@ export default function App() {
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1> <h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
{draftKind === null && selectedItem ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => void handleToggleStar(selectedItem)}
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
>
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
</Button>
) : null}
{draftKind === null && selectedItem?.kind === "chat" ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => openRenameChatDialog(selectedItem.id)}
title="Rename chat"
aria-label="Rename chat"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
</div> </div>
</div> </div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto"> <div className="flex shrink-0 items-center justify-end gap-2">
{!isSearchMode ? ( {!isSearchMode ? (
<> <Button
<select type="button"
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" variant="secondary"
value={provider} className="h-10 max-w-[44vw] gap-2 rounded-lg px-3 md:max-w-full"
onChange={(event) => { onClick={openChatSettings}
const nextProvider = event.currentTarget.value as Provider; disabled={isActiveSelectionSending}
setProvider(nextProvider); aria-label="Open chat settings"
const options = getModelOptions(modelCatalog, nextProvider); >
setModel(pickProviderModel(options, providerModelPreferences[nextProvider])); <Settings2 className="h-4 w-4 shrink-0" />
}} <span className="hidden shrink-0 sm:inline">Settings</span>
disabled={isActiveSelectionSending} <span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
> {getProviderLabel(provider)} · {model || "No model"}
{providerOptions.map((candidate) => ( </span>
<option key={candidate} value={candidate}> </Button>
{getProviderLabel(candidate)}
</option>
))}
</select>
<ModelCombobox
options={providerModelOptions}
value={model}
disabled={isActiveSelectionSending}
onChange={(nextModel) => {
const normalizedModel = nextModel.trim();
setModel(normalizedModel);
setProviderModelPreferences((current) => ({
...current,
[provider]: normalizedModel || null,
}));
}}
/>
</>
) : ( ) : (
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100"> <div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
<Globe2 className="mr-2 h-4 w-4" /> <Globe2 className="mr-2 h-4 w-4" />
@@ -3021,6 +3223,201 @@ export default function App() {
</button> </button>
</div> </div>
) : null} ) : null}
{isChatSettingsOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
}}
>
<form
role="dialog"
aria-modal="true"
aria-labelledby="chat-settings-title"
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
onSubmit={(event) => void handleChatSettingsSubmit(event)}
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
Chat settings
</h2>
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings}
aria-label="Close chat settings"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
<div className="flex items-center gap-2">
<input
value={chatSettingsTitleDraft}
onInput={(event) => {
setChatSettingsTitleDraft(event.currentTarget.value);
if (chatSettingsError) setChatSettingsError(null);
}}
maxLength={120}
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
disabled={isSavingChatSettings}
/>
{chatSettingsChatId ? (
<Button
type="button"
size="icon"
variant="secondary"
className="h-11 w-11 shrink-0 rounded-lg"
onClick={() => void handleToggleChatSettingsStar()}
disabled={isSavingChatSettings || isTogglingChatSettingsStar}
aria-label={chatSettingsStarred ? "Unstar chat" : "Star chat"}
title={chatSettingsStarred ? "Unstar chat" : "Star chat"}
>
{isTogglingChatSettingsStar ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Star className={cn("h-4 w-4", chatSettingsStarred ? "fill-amber-300 text-amber-300" : "")} />
)}
</Button>
) : null}
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(9rem,0.7fr)_minmax(14rem,1fr)]">
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
<select
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
value={chatSettingsProviderDraft}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setChatSettingsProviderDraft(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setChatSettingsModelDraft(pickProviderModel(options, providerModelPreferences[nextProvider]));
setChatSettingsError(null);
}}
disabled={isSavingChatSettings}
>
{providerOptions.map((candidate) => (
<option key={candidate} value={candidate}>
{getProviderLabel(candidate)}
</option>
))}
</select>
</label>
<label className="block min-w-0">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
<ModelCombobox
options={chatSettingsProviderModelOptions}
value={chatSettingsModelDraft}
disabled={isSavingChatSettings}
onChange={(nextModel) => {
setChatSettingsModelDraft(nextModel.trim());
setChatSettingsError(null);
}}
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Additional system prompt</span>
<Textarea
rows={5}
value={chatSettingsPromptDraft}
onInput={(event) => {
setChatSettingsPromptDraft(event.currentTarget.value);
if (chatSettingsError) setChatSettingsError(null);
}}
placeholder="Add per-chat instructions"
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
disabled={isSavingChatSettings}
/>
</label>
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-xs font-semibold text-violet-100/72">Tools</h3>
{availableChatTools.length ? (
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
disabled={isSavingChatSettings}
>
<Check className="h-3.5 w-3.5" />
All
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setChatSettingsEnabledToolsDraft([])}
disabled={isSavingChatSettings}
>
<X className="h-3.5 w-3.5" />
None
</Button>
</div>
) : null}
</div>
<div className="space-y-2">
{availableChatTools.length ? (
availableChatTools.map((tool) => {
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
return (
<label
key={tool.name}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleChatSettingsTool(tool.name)}
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
disabled={isSavingChatSettings}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
</span>
</label>
);
})
) : (
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
No chat tools are available.
</p>
)}
</div>
</section>
</div>
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
Cancel
</Button>
<Button type="submit" disabled={isSavingChatSettings}>
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Save
</Button>
</div>
</form>
</div>
) : null}
{renameChatDialog ? ( {renameChatDialog ? (
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6" className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"

View File

@@ -9,6 +9,8 @@ export type ChatSummary = {
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
}; };
export type SearchSummary = { export type SearchSummary = {
@@ -64,6 +66,8 @@ export type ChatDetail = {
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
messages: Message[]; messages: Message[];
}; };
@@ -157,6 +161,11 @@ export type ModelCatalogResponse = {
providers: Partial<Record<Provider, ProviderModelInfo>>; providers: Partial<Record<Provider, ProviderModelInfo>>;
}; };
export type ChatToolInfo = {
name: string;
description: string;
};
export type ActiveRunsResponse = { export type ActiveRunsResponse = {
chats: string[]; chats: string[];
searches: string[]; searches: string[];
@@ -182,6 +191,8 @@ type CreateChatRequest = {
title?: string; title?: string;
provider?: Provider; provider?: Provider;
model?: string; model?: string;
additionalSystemPrompt?: string;
enabledTools?: string[];
messages?: CompletionRequestMessage[]; messages?: CompletionRequestMessage[];
}; };
@@ -257,6 +268,11 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models"); return api<ModelCatalogResponse>("/v1/models");
} }
export async function listChatTools() {
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
return data.tools;
}
export async function getActiveRuns() { export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs"); return api<ActiveRunsResponse>("/v1/active-runs");
} }
@@ -291,6 +307,17 @@ export async function updateChatStar(chatId: string, starred: boolean) {
return data.chat; return data.chat;
} }
export async function updateChatSettings(
chatId: string,
body: { title?: string; 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 }) { export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", { const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST", method: "POST",
@@ -613,6 +640,9 @@ export async function runCompletion(body: {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}) { }) {
return api<CompletionResponse>("/v1/chat-completions", { return api<CompletionResponse>("/v1/chat-completions", {
method: "POST", method: "POST",
@@ -627,6 +657,9 @@ export async function runCompletionStream(
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }