Add per-chat settings UI in web app for additional system prompt and tool checkboxes

This commit is contained in:
Agent
2026-05-24 22:04:05 +00:00
parent 93e34d086f
commit f3bb8503aa
9 changed files with 282 additions and 28 deletions

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?
lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id])
userId String?

View File

@@ -192,7 +192,43 @@ const CHAT_TOOLS: any[] = [
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
];
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
function getToolName(tool: any) {
return typeof tool?.function?.name === "string" ? tool.function.name : null;
}
export function getAvailableChatTools() {
return CHAT_TOOLS.map((tool) => {
const name = getToolName(tool);
if (!name) return null;
return {
name,
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
};
}).filter((tool): tool is { name: string; description: string } => tool !== null);
}
export function normalizeEnabledChatTools(value: unknown) {
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getEnabledToolSet(params: Pick<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",
@@ -201,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
parameters: tool.function.parameters,
strict: false,
};
});
});
}
export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " +
@@ -243,6 +280,7 @@ type ToolAwareCompletionParams = {
client: OpenAI;
model: string;
messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number;
maxTokens?: number;
@@ -384,20 +422,38 @@ function extractHtmlTitle(html: string) {
);
}
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string) {
function buildChatToolSystemPrompt(params: Pick<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 }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
}
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string) {
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
@@ -962,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
}
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation);
const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -976,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input,
temperature: params.temperature,
max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS,
tools: toResponsesChatTools(enabledTools),
tool_choice: "auto",
parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1031,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
}
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation);
const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1045,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tools: enabledTools,
tool_choice: "auto",
} as any);
rawResponses.push(completion);
@@ -1139,7 +1197,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation);
const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1153,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
input,
temperature: params.temperature,
max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS,
tools: toResponsesChatTools(enabledTools),
tool_choice: "auto",
parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1265,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation);
const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1279,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tools: enabledTools,
tool_choice: "auto",
stream: true,
stream_options: { include_usage: true },

View File

@@ -1,7 +1,7 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
@@ -47,13 +47,15 @@ export async function runMultiplex(req: MultiplexRequest): Promise<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,
@@ -67,12 +69,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 === "xai") {
} else if (req.provider === "xai" && enabledTools.length > 0) {
const client = xaiClient();
const r = await runToolAwareChatCompletions({
client,
model: req.model,
messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
@@ -86,8 +89,8 @@ export async function runMultiplex(req: MultiplexRequest): Promise<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,

View File

@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import {
buildToolLogMessageData,
normalizeEnabledChatTools,
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream,
@@ -76,12 +77,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
try {
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
const streamEvents =
req.provider === "openai"
req.provider === "openai" && enabledTools.length > 0
? runToolAwareOpenAIChatStream({
client,
model: req.model,
messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
@@ -91,7 +94,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId: chatId ?? undefined,
},
})
: req.provider === "hermes-agent"
: req.provider === "hermes-agent" || enabledTools.length === 0
? runPlainChatCompletionsStream({
client,
model: req.model,
@@ -109,6 +112,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
client,
model: req.model,
messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,

View File

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

View File

@@ -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";
@@ -15,6 +16,8 @@ import { exaClient } from "./search/exa.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";
@@ -168,6 +171,8 @@ const CompletionStreamBody = z
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
@@ -193,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({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
@@ -382,6 +422,8 @@ async function listWorkspaceItems() {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
}),
prisma.search.findMany({
@@ -641,6 +683,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 {
@@ -668,6 +715,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
@@ -680,6 +729,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) => {
@@ -708,6 +759,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) => ({
@@ -728,6 +781,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
return { chat: serializeProviderFields(chat) };
@@ -736,13 +791,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");
@@ -758,6 +822,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
@@ -783,6 +849,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
if (!existing) return app.httpErrors.notFound("chat not found");
@@ -804,6 +872,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
@@ -924,6 +994,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
@@ -1123,6 +1195,8 @@ export async function registerRoutes(app: FastifyInstance) {
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
@@ -1143,7 +1217,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,
@@ -1174,14 +1248,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));
}