Compare commits
4 Commits
600bc3befc
...
f71b69ca8b
| Author | SHA1 | Date | |
|---|---|---|---|
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 |
@@ -42,6 +42,23 @@ Chat upload limits:
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
|
||||
## Chat Tools
|
||||
|
||||
### `GET /v1/chat-tools`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{ "name": "web_search", "description": "..." },
|
||||
{ "name": "fetch_url", "description": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
|
||||
### `GET /v1/active-runs`
|
||||
@@ -77,7 +94,9 @@ Behavior notes:
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
"lastUsedModel": "gpt-4.1-mini"
|
||||
"lastUsedModel": "gpt-4.1-mini",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
},
|
||||
{
|
||||
"type": "search",
|
||||
@@ -111,6 +130,8 @@ Behavior notes:
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system|user|assistant|tool",
|
||||
@@ -126,13 +147,17 @@ Behavior notes:
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: `{ "title": string }`
|
||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- Renaming updates the returned chat's `updatedAt`.
|
||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
### `PATCH /v1/chats/:chatId/star`
|
||||
@@ -237,6 +262,8 @@ Notes:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -256,6 +283,8 @@ Notes:
|
||||
Behavior notes:
|
||||
- If `chatId` is present, server validates chat existence.
|
||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||
@@ -390,7 +419,9 @@ Behavior notes:
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null"
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -441,6 +472,8 @@ Behavior notes:
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [Message]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -49,6 +49,8 @@ Authentication:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -60,6 +62,8 @@ Notes:
|
||||
- If `chatId` is provided, backend validates it exists.
|
||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
|
||||
@@ -661,6 +661,7 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||
@@ -57,6 +57,9 @@ model Chat {
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import { z } from "zod";
|
||||
import { env } from "../env.js";
|
||||
import { exaClient } from "../search/exa.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";
|
||||
|
||||
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] : []),
|
||||
];
|
||||
|
||||
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;
|
||||
return {
|
||||
type: "function",
|
||||
@@ -197,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
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));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
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));
|
||||
|
||||
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> {
|
||||
@@ -957,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
|
||||
}
|
||||
|
||||
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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -971,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1026,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
}
|
||||
|
||||
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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1040,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
@@ -1114,7 +1177,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
@@ -1134,7 +1197,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
|
||||
export async function* runToolAwareOpenAIChatStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): 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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1148,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1260,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
export async function* runToolAwareChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): 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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1274,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -1403,7 +1468,7 @@ export async function* runPlainChatCompletionsStream(
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
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) {
|
||||
return value.replace(/"/g, """);
|
||||
}
|
||||
@@ -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 =
|
||||
"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[]) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
let usage: MultiplexResponse["usage"] | undefined;
|
||||
let raw: unknown;
|
||||
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 r = await runToolAwareOpenAIChat({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
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 r = await runToolAwareChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -84,12 +89,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "hermes-agent") {
|
||||
const client = hermesAgentClient();
|
||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const r = await runPlainChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -104,7 +110,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
} else if (req.provider === "anthropic") {
|
||||
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 r = await client.messages.create({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
normalizeEnabledChatTools,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
@@ -76,12 +77,15 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
const streamEvents =
|
||||
req.provider === "openai"
|
||||
req.provider === "openai" && enabledTools.length > 0
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -90,11 +94,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: req.provider === "hermes-agent"
|
||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||
? runPlainChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -107,6 +112,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -146,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
} else if (req.provider === "anthropic") {
|
||||
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 stream = await client.messages.create({
|
||||
|
||||
@@ -36,6 +36,9 @@ export type MultiplexRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { env } from "./env.js";
|
||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||
import { runMultiplex } from "./llm/multiplexer.js";
|
||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||
import { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
@@ -16,6 +17,8 @@ import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||
import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||
|
||||
type IncomingChatMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
@@ -48,6 +51,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||
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[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
@@ -132,6 +172,9 @@ const CompletionStreamBody = z
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
@@ -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({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
@@ -339,6 +417,8 @@ const chatSummarySelect = {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
@@ -716,6 +796,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { providers: getModelCatalogSnapshot() };
|
||||
});
|
||||
|
||||
app.get("/v1/chat-tools", async (req) => {
|
||||
requireAdmin(req);
|
||||
return { tools: getAvailableChatTools() };
|
||||
});
|
||||
|
||||
app.get("/v1/active-runs", async (req) => {
|
||||
requireAdmin(req);
|
||||
return {
|
||||
@@ -746,6 +831,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
title: z.string().optional(),
|
||||
provider: ProviderSchema.optional(),
|
||||
model: z.string().trim().min(1).optional(),
|
||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
messages: z.array(CompletionMessageSchema).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -774,6 +861,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: body.model,
|
||||
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||
lastUsedModel: body.model,
|
||||
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
||||
enabledTools: body.enabledTools as any,
|
||||
messages: body.messages?.length
|
||||
? {
|
||||
create: body.messages.map((message) => ({
|
||||
@@ -793,13 +882,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ title: z.string().trim().min(1) });
|
||||
const Body = z.object({
|
||||
title: z.string().trim().min(1).optional(),
|
||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
});
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const data: Record<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({
|
||||
where: { id: chatId },
|
||||
data: { title: body.title },
|
||||
data: data as any,
|
||||
});
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
@@ -1211,13 +1309,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const parsed = Body.safeParse(req.body);
|
||||
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
|
||||
if (body.chatId) {
|
||||
@@ -1230,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
const result = await runMultiplex(body);
|
||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||
|
||||
return {
|
||||
chatId: body.chatId ?? null,
|
||||
@@ -1244,7 +1345,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||
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
|
||||
if (body.chatId) {
|
||||
@@ -1261,14 +1362,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
const stream = startActiveChatStream(body.chatId, body);
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(body)) {
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
|
||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal 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\./);
|
||||
});
|
||||
@@ -124,6 +124,7 @@ export class SybilApiClient {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
539
web/src/App.tsx
539
web/src/App.tsx
@@ -1,5 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe2,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rabbit,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Settings2,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -18,6 +35,7 @@ import {
|
||||
attachSearchStream,
|
||||
getActiveRuns,
|
||||
getChat,
|
||||
listChatTools,
|
||||
listModels,
|
||||
getSearch,
|
||||
listWorkspaceItems,
|
||||
@@ -27,6 +45,7 @@ import {
|
||||
updateChatTitle,
|
||||
updateChatStar,
|
||||
updateSearchStar,
|
||||
updateChatSettings,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ActiveRunsResponse,
|
||||
@@ -34,6 +53,7 @@ import {
|
||||
type Provider,
|
||||
type ChatDetail,
|
||||
type ChatSummary,
|
||||
type ChatToolInfo,
|
||||
type CompletionRequestMessage,
|
||||
type Message,
|
||||
type SearchDetail,
|
||||
@@ -379,6 +399,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function getToolLabel(name: string) {
|
||||
if (name === "web_search") return "Web search";
|
||||
if (name === "fetch_url") return "Fetch URL";
|
||||
if (name === "codex_exec") return "Codex";
|
||||
if (name === "shell_exec") return "Shell";
|
||||
return name
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
|
||||
return availableTools.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
||||
const available = new Set(availableTools.map((tool) => tool.name));
|
||||
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
|
||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||
available.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||
return {
|
||||
@@ -748,6 +792,7 @@ export default function App() {
|
||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||
const [model, setModel] = useState(() => {
|
||||
const stored = loadStoredModelPreferences();
|
||||
@@ -774,6 +819,18 @@ export default function App() {
|
||||
const [renameChatDraft, setRenameChatDraft] = useState("");
|
||||
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
||||
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 transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -899,6 +956,18 @@ export default function App() {
|
||||
searchRunCountersRef.current.clear();
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsChatSettingsOpen(false);
|
||||
setIsSavingChatSettings(false);
|
||||
setIsTogglingChatSettingsStar(false);
|
||||
setChatSettingsError(null);
|
||||
setDraftChatTitle("");
|
||||
setChatSettingsTitleDraft("");
|
||||
setChatSettingsProviderDraft("openai");
|
||||
setChatSettingsModelDraft("");
|
||||
setChatSettingsPromptDraft("");
|
||||
setChatSettingsEnabledToolsDraft([]);
|
||||
setAdditionalSystemPrompt("");
|
||||
setEnabledTools([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setQuickPrompt("");
|
||||
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 () => {
|
||||
try {
|
||||
const data = await getActiveRuns();
|
||||
@@ -1020,7 +1104,7 @@ export default function App() {
|
||||
if (!isAuthenticated) return;
|
||||
const preferredSelection = initialRouteSelectionRef.current;
|
||||
initialRouteSelectionRef.current = null;
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1065,6 +1149,10 @@ export default function App() {
|
||||
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||
const chatSettingsProviderModelOptions = useMemo(
|
||||
() => getModelOptions(modelCatalog, chatSettingsProviderDraft),
|
||||
[chatSettingsProviderDraft, modelCatalog]
|
||||
);
|
||||
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1267,11 +1355,6 @@ export default function App() {
|
||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||
}, [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(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||
@@ -1287,8 +1370,17 @@ export default function App() {
|
||||
setModel(nextSelection.model);
|
||||
}, [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(() => {
|
||||
if (draftKind === "chat") return "New chat";
|
||||
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
|
||||
if (draftKind === "search") return "New search";
|
||||
if (!selectedItem) return "Sybil";
|
||||
if (selectedItem.kind === "chat") {
|
||||
@@ -1299,7 +1391,7 @@ export default function App() {
|
||||
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
||||
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
||||
return "New search";
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||
}, [draftChatTitle, draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (draftKind || !selectedItem) return "Sybil";
|
||||
@@ -1331,6 +1423,11 @@ export default function App() {
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setDraftChatTitle("");
|
||||
setAdditionalSystemPrompt("");
|
||||
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
||||
setIsChatSettingsOpen(false);
|
||||
setChatSettingsError(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -1348,6 +1445,8 @@ export default function App() {
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setIsChatSettingsOpen(false);
|
||||
setChatSettingsError(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -1441,6 +1540,8 @@ export default function App() {
|
||||
initiatedModel: updatedChat.initiatedModel,
|
||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
||||
lastUsedModel: updatedChat.lastUsedModel,
|
||||
additionalSystemPrompt: updatedChat.additionalSystemPrompt,
|
||||
enabledTools: updatedChat.enabledTools,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1476,6 +1577,99 @@ export default function App() {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
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 () => {
|
||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||
const target = contextMenu.item;
|
||||
@@ -1588,6 +1805,17 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [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(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1748,9 +1976,17 @@ export default function App() {
|
||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
|
||||
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;
|
||||
setDraftKind(null);
|
||||
setDraftChatTitle("");
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
@@ -1768,6 +2004,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
@@ -2349,6 +2587,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
@@ -2527,6 +2767,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
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) {
|
||||
return (
|
||||
@@ -2773,8 +3019,8 @@ export default function App() {
|
||||
</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">
|
||||
<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">
|
||||
<div className="flex items-start gap-2">
|
||||
<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 min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
@@ -2788,68 +3034,24 @@ export default function App() {
|
||||
|
||||
<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>
|
||||
{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 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 ? (
|
||||
<>
|
||||
<select
|
||||
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"
|
||||
value={provider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isActiveSelectionSending}
|
||||
>
|
||||
{providerOptions.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 max-w-[44vw] gap-2 rounded-lg px-3 md:max-w-full"
|
||||
onClick={openChatSettings}
|
||||
disabled={isActiveSelectionSending}
|
||||
aria-label="Open chat settings"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden shrink-0 sm:inline">Settings</span>
|
||||
<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"}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<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" />
|
||||
@@ -3021,6 +3223,201 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
|
||||
@@ -9,6 +9,8 @@ export type ChatSummary = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
};
|
||||
|
||||
export type SearchSummary = {
|
||||
@@ -64,6 +66,8 @@ export type ChatDetail = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -157,6 +161,11 @@ export type ModelCatalogResponse = {
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ActiveRunsResponse = {
|
||||
chats: string[];
|
||||
searches: string[];
|
||||
@@ -182,6 +191,8 @@ type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
@@ -257,6 +268,11 @@ export async function listModels() {
|
||||
return api<ModelCatalogResponse>("/v1/models");
|
||||
}
|
||||
|
||||
export async function listChatTools() {
|
||||
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
||||
return data.tools;
|
||||
}
|
||||
|
||||
export async function getActiveRuns() {
|
||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||
}
|
||||
@@ -291,6 +307,17 @@ export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
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 }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -613,6 +640,9 @@ export async function runCompletion(body: {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
}) {
|
||||
return api<CompletionResponse>("/v1/chat-completions", {
|
||||
method: "POST",
|
||||
@@ -627,6 +657,9 @@ export async function runCompletionStream(
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
Reference in New Issue
Block a user