4 Commits

Author SHA1 Message Date
29e340fd08 quick question feature 2026-05-02 23:48:01 -07:00
6fbcaecbf8 sidebar: move settings 2026-05-02 23:20:32 -07:00
519ebd15dd ios: mac catalyst target 2026-05-02 23:18:00 -07:00
8051dd2c71 web: nicer tables 2026-05-02 23:17:52 -07:00
12 changed files with 845 additions and 128 deletions

View File

@@ -45,9 +45,29 @@ Chat upload limits:
- Response: `{ "chats": ChatSummary[] }`
### `POST /v1/chats`
- Body: `{ "title"?: string }`
- Body:
```json
{
"title": "optional title",
"provider": "optional openai|anthropic|xai",
"model": "optional model id",
"messages": [
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": []
}
]
}
```
- Response: `{ "chat": ChatSummary }`
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`.
- 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 }`
- Response: `{ "chat": ChatSummary }`

View File

@@ -19,6 +19,7 @@ Authentication:
```json
{
"chatId": "optional-chat-id",
"persist": true,
"provider": "openai|anthropic|xai",
"model": "string",
"messages": [
@@ -53,10 +54,12 @@ Authentication:
```
Notes:
- If `chatId` is omitted, backend creates a new chat.
- `persist` defaults to `true`.
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- Backend stores only new non-assistant input history rows to avoid duplicates.
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages.
- 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.
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
## Event Stream Contract
@@ -71,13 +74,15 @@ Event order:
```json
{
"type": "meta",
"chatId": "chat-id",
"callId": "llm-call-id",
"chatId": "chat-id-or-null",
"callId": "llm-call-id-or-null",
"provider": "openai",
"model": "gpt-4.1-mini"
}
```
For `persist: false` streams, `chatId` and `callId` are `null`.
### `delta`
```json
@@ -148,17 +153,22 @@ Tool-enabled streaming notes (`openai`/`xai`):
Backend database remains source of truth.
During stream:
For persisted streams:
- Client may optimistically render accumulated `delta` text.
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
On successful completion:
On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
- Backend then emits `done`.
On failure:
On persisted failure:
- Backend records call error and emits `error`.
For `persist: false` streams:
- Client may render the same `meta`, `tool_call`, `delta`, and terminal events.
- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows.
- `done.text` is the canonical assistant text if the client later imports the result into a saved chat.
Client recommendation (for iOS/web):
1. Render deltas in real time for UX.
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.

View File

@@ -1,7 +1,9 @@
targets:
SybilApp:
type: application
platform: iOS
supportedDestinations:
- iOS
- macCatalyst
deploymentTarget: "18.0"
sources:
- Sources
@@ -16,7 +18,8 @@ targets:
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.4

View File

@@ -151,25 +151,6 @@ struct SybilSidebarView: View {
}
}
Divider()
.overlay(SybilTheme.border)
Button {
viewModel.openSettings()
} label: {
Label("Settings", systemImage: "gearshape")
.font(.sybil(.subheadline, weight: .medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
)
}
.buttonStyle(.plain)
.padding(10)
}
.background(SybilTheme.panelGradient)
.navigationTitle("")
@@ -178,6 +159,17 @@ struct SybilSidebarView: View {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
viewModel.openSettings()
} label: {
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
}
.accessibilityLabel("Settings")
}
}
}

View File

@@ -10,11 +10,17 @@ import {
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
export type StreamEvent =
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "delta"; text: string }
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
| { type: "done"; text: string; usage?: StreamUsage }
| { type: "error"; message: string };
function getChatIdOrCreate(chatId?: string) {
@@ -24,39 +30,45 @@ function getChatIdOrCreate(chatId?: string) {
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
const t0 = performance.now();
const chatId = await getChatIdOrCreate(req.chatId);
const shouldPersist = req.persist !== false;
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
const call = await prisma.llmCall.create({
data: {
chatId,
provider: req.provider as any,
model: req.model,
request: req as any,
},
select: { id: true },
});
const call =
shouldPersist && chatId
? await prisma.llmCall.create({
data: {
chatId,
provider: req.provider as any,
model: req.model,
request: req as any,
},
select: { id: true },
})
: null;
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedModel: req.model,
},
}),
]);
if (shouldPersist && chatId) {
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedModel: req.model,
},
}),
]);
}
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
let text = "";
let usage: StreamEvent extends any ? any : never;
let usage: StreamUsage | undefined;
let raw: unknown = { streamed: true };
try {
@@ -73,7 +85,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
logContext: {
provider: req.provider,
model: req.model,
chatId,
chatId: chatId ?? undefined,
},
})
: runToolAwareChatCompletionsStream({
@@ -85,7 +97,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
logContext: {
provider: req.provider,
model: req.model,
chatId,
chatId: chatId ?? undefined,
},
});
for await (const ev of streamEvents) {
@@ -96,16 +108,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
}
if (ev.type === "tool_call") {
const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({
data: {
chatId: toolMessage.chatId,
role: toolMessage.role as any,
content: toolMessage.content,
name: toolMessage.name,
metadata: toolMessage.metadata as any,
},
});
if (shouldPersist && chatId) {
const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({
data: {
chatId: toolMessage.chatId,
role: toolMessage.role as any,
content: toolMessage.content,
name: toolMessage.name,
metadata: toolMessage.metadata as any,
},
});
}
yield { type: "tool_call", event: ev.event };
continue;
}
@@ -156,32 +170,36 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
const latencyMs = Math.round(performance.now() - t0);
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
latencyMs,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
});
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
latencyMs,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
});
});
}
yield { type: "done", text, usage };
} catch (e: any) {
const latencyMs = Math.round(performance.now() - t0);
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
if (shouldPersist && call) {
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
}
yield { type: "error", message: e?.message ?? String(e) };
}
}

View File

@@ -30,6 +30,7 @@ export type ChatMessage = {
export type MultiplexRequest = {
chatId?: string;
persist?: boolean;
provider: Provider;
model: string;
messages: ChatMessage[];

View File

@@ -327,10 +327,50 @@ export async function registerRoutes(app: FastifyInstance) {
app.post("/v1/chats", async (req) => {
requireAdmin(req);
const Body = z.object({ title: z.string().optional() });
const body = Body.parse(req.body ?? {});
const Body = z
.object({
title: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]).optional(),
model: z.string().trim().min(1).optional(),
messages: z.array(CompletionMessageSchema).optional(),
})
.superRefine((value, ctx) => {
if (value.provider && !value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "model is required when provider is supplied",
path: ["model"],
});
}
if (!value.provider && value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "provider is required when model is supplied",
path: ["provider"],
});
}
});
const parsed = Body.safeParse(req.body ?? {});
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const chat = await prisma.chat.create({
data: { title: body.title },
data: {
title: body.title,
initiatedProvider: body.provider as any,
initiatedModel: body.model,
lastUsedProvider: body.provider as any,
lastUsedModel: body.model,
messages: body.messages?.length
? {
create: body.messages.map((message) => ({
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.attachments?.length ? ({ attachments: message.attachments } as any) : undefined,
})),
}
: undefined,
},
select: {
id: true,
title: true,
@@ -838,7 +878,9 @@ export async function registerRoutes(app: FastifyInstance) {
});
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const msg = await prisma.message.create({
data: {
@@ -866,7 +908,9 @@ export async function registerRoutes(app: FastifyInstance) {
maxTokens: z.number().int().positive().optional(),
});
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
// ensure chat exists if provided
if (body.chatId) {
@@ -891,16 +935,29 @@ export async function registerRoutes(app: FastifyInstance) {
app.post("/v1/chat-completions/stream", async (req, reply) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});
const Body = z
.object({
chatId: z.string().optional(),
persist: z.boolean().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
})
.superRefine((value, ctx) => {
if (value.persist === false && value.chatId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "chatId must be omitted when persist is false",
path: ["chatId"],
});
}
});
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
// ensure chat exists if provided
if (body.chatId) {
@@ -909,7 +966,7 @@ export async function registerRoutes(app: FastifyInstance) {
}
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) {
if (body.persist !== false && body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
@@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
};
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
type ProviderModelPreferences = Record<Provider, string | null>;
type QuickQuestionModelSelection = {
provider: Provider;
modelPreferences: ProviderModelPreferences;
};
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
openai: null,
anthropic: null,
@@ -292,6 +298,37 @@ function loadStoredModelPreferences() {
}
}
function normalizeStoredProvider(value: unknown): Provider {
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai";
}
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
const parsed = value as Partial<Record<Provider, unknown>>;
return {
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
};
}
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
if (typeof window === "undefined") {
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
}
try {
const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY);
if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown };
return {
provider: normalizeStoredProvider(parsed.provider),
modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences),
};
} catch {
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
}
}
function pickProviderModel(options: string[], preferred: string | null) {
if (preferred?.trim()) return preferred.trim();
return options[0] ?? "";
@@ -620,6 +657,22 @@ export default function App() {
const stored = loadStoredModelPreferences();
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
});
const [quickProvider, setQuickProvider] = useState<Provider>(() => loadStoredQuickQuestionModelSelection().provider);
const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState<ProviderModelPreferences>(
() => loadStoredQuickQuestionModelSelection().modelPreferences
);
const [quickModel, setQuickModel] = useState(() => {
const stored = loadStoredQuickQuestionModelSelection();
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
});
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
const [quickPrompt, setQuickPrompt] = useState("");
const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState<string | null>(null);
const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null);
const [quickQuestionMessages, setQuickQuestionMessages] = useState<Message[]>([]);
const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false);
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null);
@@ -631,6 +684,7 @@ export default function App() {
const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const searchRunAbortRef = useRef<AbortController | null>(null);
const quickQuestionAbortRef = useRef<AbortController | null>(null);
const searchRunCounterRef = useRef(0);
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
@@ -713,6 +767,12 @@ export default function App() {
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setIsQuickQuestionOpen(false);
setQuickPrompt("");
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
setError(null);
};
@@ -846,6 +906,7 @@ export default function App() {
}, [isAuthenticated, selectedItem]);
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
useEffect(() => {
if (model.trim()) return;
@@ -859,6 +920,46 @@ export default function App() {
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
}, [providerModelPreferences]);
useEffect(() => {
if (quickModel.trim()) return;
setQuickModel((current) => {
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
});
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(
QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({
provider: quickProvider,
modelPreferences: quickProviderModelPreferences,
} satisfies QuickQuestionModelSelection)
);
}, [quickProvider, quickProviderModelPreferences]);
useEffect(() => {
if (!isQuickQuestionOpen || typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
if (!textarea) return;
textarea.focus();
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
if (textarea.value.length > 0) {
textarea.select();
}
});
}, [isQuickQuestionOpen]);
useEffect(() => {
if (typeof document === "undefined") return;
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [quickPrompt, isQuickQuestionOpen]);
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
const isChatReplyStreamingInView =
isSending &&
@@ -933,6 +1034,8 @@ export default function App() {
return () => {
searchRunAbortRef.current?.abort();
searchRunAbortRef.current = null;
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
};
}, []);
@@ -960,6 +1063,18 @@ export default function App() {
}
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
}, [isSearchMode, messages, pendingChatState, selectedItem]);
const quickAnswerText = useMemo(() => {
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
const message = quickQuestionMessages[index];
if (message.role === "assistant") return message.content;
}
return "";
}, [quickQuestionMessages]);
const canConvertQuickQuestion =
Boolean(quickSubmittedPrompt?.trim()) &&
Boolean(quickSubmittedModelSelection?.model.trim()) &&
Boolean(quickAnswerText.trim()) &&
!isQuickQuestionSending;
const selectedChatSummary = useMemo(() => {
if (!selectedItem || selectedItem.kind !== "chat") return null;
@@ -1028,6 +1143,12 @@ export default function App() {
setIsMobileSidebarOpen(false);
};
const handleOpenQuickQuestion = () => {
setQuickQuestionError(null);
setIsQuickQuestionOpen(true);
setIsMobileSidebarOpen(false);
};
const handleCreateSearch = () => {
setError(null);
setContextMenu(null);
@@ -1068,6 +1189,15 @@ export default function App() {
if (!hasPrimaryModifier || event.altKey) return;
const key = event.key.toLowerCase();
if (key === "i" && !event.shiftKey) {
event.preventDefault();
setQuickQuestionError(null);
setIsQuickQuestionOpen((current) => !current);
return;
}
if (isQuickQuestionOpen) return;
if (key === "j") {
event.preventDefault();
if (event.shiftKey) {
@@ -1087,7 +1217,7 @@ export default function App() {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredSidebarItems, isAuthenticated]);
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault();
@@ -1138,6 +1268,17 @@ export default function App() {
};
}, [contextMenu]);
useEffect(() => {
if (!isQuickQuestionOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setIsQuickQuestionOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isQuickQuestionOpen]);
const handleOpenAttachmentPicker = () => {
fileInputRef.current?.click();
};
@@ -1587,6 +1728,182 @@ export default function App() {
}
};
const handleSendQuickQuestion = async () => {
const content = quickPrompt.trim();
if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return;
const selectedModel = quickModel.trim();
if (!selectedModel) {
setQuickQuestionError("No model available for selected provider");
return;
}
const now = new Date().toISOString();
const optimisticAssistantMessage: Message = {
id: `temp-assistant-quick-${Date.now()}`,
createdAt: now,
role: "assistant",
content: "",
name: null,
metadata: null,
};
quickQuestionAbortRef.current?.abort();
const abortController = new AbortController();
quickQuestionAbortRef.current = abortController;
setQuickQuestionError(null);
setQuickSubmittedPrompt(content);
setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel });
setQuickQuestionMessages([optimisticAssistantMessage]);
setIsQuickQuestionSending(true);
let streamErrorMessage: string | null = null;
try {
await runCompletionStream(
{
persist: false,
provider: quickProvider,
model: selectedModel,
messages: [{ role: "user", content }],
},
{
onToolCall: (payload) => {
setQuickQuestionMessages((current) => {
if (
current.some(
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
)
) {
return current;
}
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = current.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
);
if (assistantIndex < 0) return current.concat(toolMessage);
return [
...current.slice(0, assistantIndex),
toolMessage,
...current.slice(assistantIndex),
];
});
},
onDelta: (payload) => {
if (!payload.text) return;
setQuickQuestionMessages((current) => {
let updated = false;
const nextMessages = current.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
if (!isTarget) return message;
updated = true;
return { ...message, content: message.content + payload.text };
});
return updated ? nextMessages : current;
});
},
onDone: (payload) => {
setQuickQuestionMessages((current) => {
let updated = false;
const nextMessages = current.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
if (!isTarget) return message;
updated = true;
return { ...message, content: payload.text };
});
return updated ? nextMessages : current;
});
},
onError: (payload) => {
streamErrorMessage = payload.message;
},
},
{ signal: abortController.signal }
);
if (streamErrorMessage) {
throw new Error(streamErrorMessage);
}
} catch (err) {
if (abortController.signal.aborted) return;
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setQuickQuestionError(message);
}
} finally {
if (quickQuestionAbortRef.current === abortController) {
quickQuestionAbortRef.current = null;
}
if (!abortController.signal.aborted) {
setIsQuickQuestionSending(false);
}
}
};
const handleConvertQuickQuestionToChat = async () => {
const question = quickSubmittedPrompt?.trim();
const answer = quickAnswerText.trim();
const selection = quickSubmittedModelSelection;
if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return;
setQuickQuestionError(null);
setIsConvertingQuickQuestion(true);
try {
const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question";
const chat = await createChat({
title,
provider: selection.provider,
model: selection.model,
messages: [
{ role: "user", content: question },
{ role: "assistant", content: answer },
],
});
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setIsQuickQuestionOpen(false);
setProvider(selection.provider);
setModel(selection.model);
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: [],
});
setSelectedSearch(null);
await refreshCollections({ kind: "chat", id: chat.id });
await refreshChat(chat.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setQuickQuestionError(message);
}
} finally {
setIsConvertingQuickQuestion(false);
}
};
const handleSend = async () => {
const content = composer.trim();
const attachments = pendingAttachments;
@@ -1683,13 +2000,30 @@ export default function App() {
</div>
<div className="space-y-3 px-3 pb-3">
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
{primaryShortcutModifier} J
</span>
</Button>
<div className="flex gap-2">
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
{primaryShortcutModifier} J
</span>
</Button>
<div className="group relative">
<Button
className="h-11 w-11 rounded-lg"
onClick={handleOpenQuickQuestion}
size="icon"
variant="secondary"
title={`${primaryShortcutModifier}+i`}
aria-label="Quick question"
>
<Rabbit className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-violet-300/22 bg-[hsl(238_48%_7%)] px-2 py-1 text-xs font-semibold text-violet-100/90 opacity-0 shadow-xl shadow-black/35 transition group-hover:opacity-100 group-focus-within:opacity-100">
{primaryShortcutModifier}+i
</span>
</div>
</div>
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
<Search className="h-4 w-4" />
New search
@@ -1961,6 +2295,136 @@ export default function App() {
</button>
</div>
) : null}
{isQuickQuestionOpen ? (
<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) setIsQuickQuestionOpen(false);
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="quick-question-title"
className="glass-panel flex max-h-[88vh] w-full max-w-3xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 id="quick-question-title" className="text-sm font-semibold text-violet-50">
Quick question
</h2>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setIsQuickQuestionOpen(false)}
aria-label="Close quick question"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3">
<Textarea
id="quick-question-input"
rows={2}
value={quickPrompt}
onInput={(event) => {
const textarea = event.currentTarget;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
const nextPrompt = textarea.value;
if (nextPrompt !== quickPrompt) {
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
setIsQuickQuestionSending(false);
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
}
setQuickPrompt(nextPrompt);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSendQuickQuestion();
}
}}
placeholder="Ask Sybil..."
className="max-h-36 min-h-[4.75rem] resize-none overflow-y-auto border-violet-300/24 bg-background/72 text-base text-violet-50 placeholder:text-violet-200/45"
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
/>
<div className="h-[min(34vh,22rem)] overflow-y-auto rounded-xl border border-violet-300/16 bg-background/38 px-3 py-4">
{quickQuestionMessages.length ? (
<ChatMessagesPanel messages={quickQuestionMessages} isLoading={false} isSending={isQuickQuestionSending} />
) : null}
{quickQuestionError ? (
<p className="text-sm text-rose-300">{quickQuestionError}</p>
) : null}
</div>
</div>
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<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={quickProvider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
}}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<ModelCombobox
options={quickProviderModelOptions}
value={quickModel}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
onChange={(nextModel) => {
const normalizedModel = nextModel.trim();
setQuickModel(normalizedModel);
setQuickProviderModelPreferences((current) => ({
...current,
[quickProvider]: normalizedModel || null,
}));
}}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
className="gap-2"
onClick={() => void handleConvertQuickQuestionToChat()}
disabled={!canConvertQuickQuestion || isConvertingQuickQuestion}
>
<MessageSquare className="h-4 w-4" />
Convert to chat
</Button>
<Button
type="button"
className="h-10 w-10 rounded-lg"
onClick={() => void handleSendQuickQuestion()}
size="icon"
disabled={isQuickQuestionSending || isConvertingQuickQuestion || !quickPrompt.trim()}
aria-label="Ask quick question"
>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</section>
</div>
) : null}
</div>
);
}

View File

@@ -12,9 +12,16 @@ type Props = {
type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -26,10 +33,26 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
return `Tool failed: ${metadata.error.trim()}`;
}
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
if (message.content.trim()) return message.content.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
const raw = metadata.toolName?.trim() || message.name?.trim();
if (!raw) return "Tool call";
return raw
.replace(/_/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
.join(" ");
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
@@ -37,6 +60,27 @@ function getToolIconName(toolName: string | null | undefined) {
return "generic";
}
function formatDuration(durationMs: unknown) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
return `${Math.round(durationMs)} ms`;
}
function formatToolTimestamp(...values: Array<string | null | undefined>) {
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
if (!value) return null;
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
}
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
return [
isFailed ? "Failed" : "Completed",
formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
]
.filter(Boolean)
.join(" • ");
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
@@ -50,18 +94,39 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const isFailed = toolLogMetadata.status === "failed";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
return (
<div key={message.id} className="flex justify-start">
<div
className={cn(
"inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
)}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
>
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
<span>{getToolSummary(message, toolLogMetadata)}</span>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
{toolSummary}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useMemo } from "preact/hooks";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { marked, Renderer } from "marked";
import { cn } from "@/lib/utils";
type MarkdownMode = "default" | "citationTokens";
@@ -21,8 +21,15 @@ function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitatio
});
}
const markdownRenderer = new Renderer();
const renderTable = markdownRenderer.table.bind(markdownRenderer);
markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
};
function renderMarkdown(markdown: string) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true }) as string;
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
}

View File

@@ -83,6 +83,77 @@ textarea {
word-break: break-word;
}
.md-table-scroll {
max-width: 100%;
margin: 0.35rem 0 1rem;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
.md-content table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.94em;
line-height: 1.48;
}
.md-table-scroll::-webkit-scrollbar {
height: 0.45rem;
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: hsl(263 78% 72% / 0.34);
}
.md-content th,
.md-content td {
padding: 0.48rem 0.7rem;
border-right: 1px solid hsl(var(--border) / 0.72);
border-bottom: 1px solid hsl(var(--border) / 0.7);
text-align: left;
vertical-align: top;
word-break: normal;
}
.md-content th:last-child,
.md-content td:last-child {
border-right: 0;
}
.md-content tr:last-child td {
border-bottom: 0;
}
.md-content th {
background: hsl(251 40% 15% / 0.92);
color: hsl(258 36% 98%);
font-weight: 700;
white-space: nowrap;
}
.md-content td {
color: hsl(258 34% 94% / 0.96);
}
.md-content tbody tr:nth-child(odd) td {
background: hsl(242 32% 10% / 0.58);
}
.md-content tbody tr:nth-child(even) td {
background: hsl(252 36% 13% / 0.46);
}
.md-content tbody tr:hover td {
background: hsl(263 46% 20% / 0.48);
}
.md-content p + p {
margin-top: 0.85rem;
}

View File

@@ -148,13 +148,20 @@ type CompletionResponse = {
};
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
};
type CreateChatRequest = {
title?: string;
provider?: Provider;
model?: string;
messages?: CompletionRequestMessage[];
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN;
@@ -210,10 +217,11 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function createChat(title?: string) {
export async function createChat(input?: string | CreateChatRequest) {
const body = typeof input === "string" ? { title: input } : input ?? {};
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: JSON.stringify({ title }),
body: JSON.stringify(body),
});
return data.chat;
}
@@ -443,7 +451,8 @@ export async function runCompletion(body: {
export async function runCompletionStream(
body: {
chatId: string;
chatId?: string | null;
persist?: boolean;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];