2 Commits

13 changed files with 395 additions and 39 deletions

View File

@@ -631,6 +631,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

@@ -51,6 +51,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";
@@ -15,6 +16,8 @@ import { exaClient } from "./search/exa.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";
@@ -47,6 +50,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;
@@ -131,6 +171,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(),
}) })
@@ -155,6 +198,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(),
@@ -344,6 +422,8 @@ async function listWorkspaceItems() {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}), }),
prisma.search.findMany({ prisma.search.findMany({
@@ -603,6 +683,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 {
@@ -630,6 +715,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chats: chats.map((chat) => serializeProviderFields(chat)) }; return { chats: chats.map((chat) => serializeProviderFields(chat)) };
@@ -642,6 +729,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) => {
@@ -670,6 +759,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) => ({
@@ -690,6 +781,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chat: serializeProviderFields(chat) }; return { chat: serializeProviderFields(chat) };
@@ -698,13 +791,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");
@@ -720,6 +822,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
@@ -745,6 +849,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
if (!existing) return app.httpErrors.notFound("chat not found"); if (!existing) return app.httpErrors.notFound("chat not found");
@@ -766,6 +872,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
@@ -886,6 +994,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
@@ -1085,13 +1195,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) {
@@ -1104,7 +1217,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,
@@ -1118,7 +1231,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) {
@@ -1135,14 +1248,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

@@ -100,6 +100,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,20 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact"; import {
Check,
ChevronDown,
Globe2,
LoaderCircle,
Menu,
MessageSquare,
Paperclip,
Plus,
Rabbit,
Search,
SendHorizontal,
Settings2,
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,12 +33,14 @@ import {
attachSearchStream, attachSearchStream,
getActiveRuns, getActiveRuns,
getChat, getChat,
listChatTools,
listModels, listModels,
getSearch, getSearch,
listWorkspaceItems, listWorkspaceItems,
runCompletionStream, runCompletionStream,
runSearchStream, runSearchStream,
suggestChatTitle, suggestChatTitle,
updateChatSettings,
getMessageAttachments, getMessageAttachments,
type ChatAttachment, type ChatAttachment,
type ActiveRunsResponse, type ActiveRunsResponse,
@@ -31,6 +48,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,
@@ -371,6 +389,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 {
@@ -730,6 +772,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();
@@ -752,6 +795,9 @@ export default function App() {
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false); const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null); const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
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);
@@ -876,6 +922,9 @@ export default function App() {
searchRunCountersRef.current.clear(); searchRunCountersRef.current.clear();
setComposer(""); setComposer("");
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false);
setAdditionalSystemPrompt("");
setEnabledTools([]);
setIsQuickQuestionOpen(false); setIsQuickQuestionOpen(false);
setQuickPrompt(""); setQuickPrompt("");
setQuickSubmittedPrompt(null); setQuickSubmittedPrompt(null);
@@ -940,6 +989,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();
@@ -992,7 +1056,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(() => {
@@ -1254,6 +1318,19 @@ export default function App() {
setModel(nextSelection.model); setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]); }, [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(() => { const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat"; if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search"; if (draftKind === "search") return "New search";

View File

@@ -7,6 +7,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 = {
@@ -58,6 +60,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[];
}; };
@@ -149,6 +153,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[];
@@ -174,6 +183,8 @@ type CreateChatRequest = {
title?: string; title?: string;
provider?: Provider; provider?: Provider;
model?: string; model?: string;
additionalSystemPrompt?: string;
enabledTools?: string[];
messages?: CompletionRequestMessage[]; messages?: CompletionRequestMessage[];
}; };
@@ -237,6 +248,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");
} }
@@ -263,6 +279,14 @@ export async function updateChatTitle(chatId: string, title: string) {
return data.chat; 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 }) { 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",
@@ -569,6 +593,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",
@@ -583,6 +610,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 }