Compare commits
4 Commits
2313e560e8
...
29e340fd08
| Author | SHA1 | Date | |
|---|---|---|---|
| 29e340fd08 | |||
| 6fbcaecbf8 | |||
| 519ebd15dd | |||
| 8051dd2c71 |
@@ -45,9 +45,29 @@ Chat upload limits:
|
|||||||
- Response: `{ "chats": ChatSummary[] }`
|
- Response: `{ "chats": ChatSummary[] }`
|
||||||
|
|
||||||
### `POST /v1/chats`
|
### `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 }`
|
- 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`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: `{ "title": string }`
|
- Body: `{ "title": string }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Authentication:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai",
|
"provider": "openai|anthropic|xai",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -53,10 +54,12 @@ Authentication:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
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.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- Backend stores only new non-assistant input history rows to avoid duplicates.
|
- 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.
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages.
|
- 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
|
## Event Stream Contract
|
||||||
|
|
||||||
@@ -71,13 +74,15 @@ Event order:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "meta",
|
"type": "meta",
|
||||||
"chatId": "chat-id",
|
"chatId": "chat-id-or-null",
|
||||||
"callId": "llm-call-id",
|
"callId": "llm-call-id-or-null",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-4.1-mini"
|
"model": "gpt-4.1-mini"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||||
|
|
||||||
### `delta`
|
### `delta`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -148,17 +153,22 @@ Tool-enabled streaming notes (`openai`/`xai`):
|
|||||||
|
|
||||||
Backend database remains source of truth.
|
Backend database remains source of truth.
|
||||||
|
|
||||||
During stream:
|
For persisted streams:
|
||||||
- Client may optimistically render accumulated `delta` text.
|
- 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.
|
- 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 persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||||
- Backend then emits `done`.
|
- Backend then emits `done`.
|
||||||
|
|
||||||
On failure:
|
On persisted failure:
|
||||||
- Backend records call error and emits `error`.
|
- 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):
|
Client recommendation (for iOS/web):
|
||||||
1. Render deltas in real time for UX.
|
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.
|
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
targets:
|
targets:
|
||||||
SybilApp:
|
SybilApp:
|
||||||
type: application
|
type: application
|
||||||
platform: iOS
|
supportedDestinations:
|
||||||
|
- iOS
|
||||||
|
- macCatalyst
|
||||||
deploymentTarget: "18.0"
|
deploymentTarget: "18.0"
|
||||||
sources:
|
sources:
|
||||||
- Sources
|
- Sources
|
||||||
@@ -16,7 +18,8 @@ targets:
|
|||||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
SWIFT_VERSION: 6.0
|
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
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.4
|
MARKETING_VERSION: 1.4
|
||||||
|
|||||||
@@ -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)
|
.background(SybilTheme.panelGradient)
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
@@ -178,6 +159,17 @@ struct SybilSidebarView: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
SybilWordmark(size: 18)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import {
|
|||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
|
type StreamUsage = {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StreamEvent =
|
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: "tool_call"; event: ToolExecutionEvent }
|
||||||
| { type: "delta"; text: string }
|
| { 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 };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
function getChatIdOrCreate(chatId?: string) {
|
function getChatIdOrCreate(chatId?: string) {
|
||||||
@@ -24,39 +30,45 @@ function getChatIdOrCreate(chatId?: string) {
|
|||||||
|
|
||||||
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
|
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
|
||||||
const t0 = performance.now();
|
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({
|
const call =
|
||||||
data: {
|
shouldPersist && chatId
|
||||||
chatId,
|
? await prisma.llmCall.create({
|
||||||
provider: req.provider as any,
|
data: {
|
||||||
model: req.model,
|
chatId,
|
||||||
request: req as any,
|
provider: req.provider as any,
|
||||||
},
|
model: req.model,
|
||||||
select: { id: true },
|
request: req as any,
|
||||||
});
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
await prisma.$transaction([
|
if (shouldPersist && chatId) {
|
||||||
prisma.chat.update({
|
await prisma.$transaction([
|
||||||
where: { id: chatId },
|
prisma.chat.update({
|
||||||
data: {
|
where: { id: chatId },
|
||||||
lastUsedProvider: req.provider as any,
|
data: {
|
||||||
lastUsedModel: req.model,
|
lastUsedProvider: req.provider as any,
|
||||||
},
|
lastUsedModel: req.model,
|
||||||
}),
|
},
|
||||||
prisma.chat.updateMany({
|
}),
|
||||||
where: { id: chatId, initiatedProvider: null },
|
prisma.chat.updateMany({
|
||||||
data: {
|
where: { id: chatId, initiatedProvider: null },
|
||||||
initiatedProvider: req.provider as any,
|
data: {
|
||||||
initiatedModel: req.model,
|
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 text = "";
|
||||||
let usage: StreamEvent extends any ? any : never;
|
let usage: StreamUsage | undefined;
|
||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +85,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
logContext: {
|
logContext: {
|
||||||
provider: req.provider,
|
provider: req.provider,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
chatId,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: runToolAwareChatCompletionsStream({
|
: runToolAwareChatCompletionsStream({
|
||||||
@@ -85,7 +97,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
logContext: {
|
logContext: {
|
||||||
provider: req.provider,
|
provider: req.provider,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
chatId,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for await (const ev of streamEvents) {
|
for await (const ev of streamEvents) {
|
||||||
@@ -96,16 +108,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "tool_call") {
|
if (ev.type === "tool_call") {
|
||||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
if (shouldPersist && chatId) {
|
||||||
await prisma.message.create({
|
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||||
data: {
|
await prisma.message.create({
|
||||||
chatId: toolMessage.chatId,
|
data: {
|
||||||
role: toolMessage.role as any,
|
chatId: toolMessage.chatId,
|
||||||
content: toolMessage.content,
|
role: toolMessage.role as any,
|
||||||
name: toolMessage.name,
|
content: toolMessage.content,
|
||||||
metadata: toolMessage.metadata as any,
|
name: toolMessage.name,
|
||||||
},
|
metadata: toolMessage.metadata as any,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
yield { type: "tool_call", event: ev.event };
|
yield { type: "tool_call", event: ev.event };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -156,32 +170,36 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
if (shouldPersist && chatId && call) {
|
||||||
await tx.message.create({
|
await prisma.$transaction(async (tx) => {
|
||||||
data: { chatId, role: "assistant" as any, content: text },
|
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 };
|
yield { type: "done", text, usage };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
await prisma.llmCall.update({
|
if (shouldPersist && call) {
|
||||||
where: { id: call.id },
|
await prisma.llmCall.update({
|
||||||
data: {
|
where: { id: call.id },
|
||||||
error: e?.message ?? String(e),
|
data: {
|
||||||
latencyMs,
|
error: e?.message ?? String(e),
|
||||||
},
|
latencyMs,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
yield { type: "error", message: e?.message ?? String(e) };
|
yield { type: "error", message: e?.message ?? String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type ChatMessage = {
|
|||||||
|
|
||||||
export type MultiplexRequest = {
|
export type MultiplexRequest = {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
|
persist?: boolean;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
|||||||
@@ -327,10 +327,50 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Body = z.object({ title: z.string().optional() });
|
const Body = z
|
||||||
const body = Body.parse(req.body ?? {});
|
.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({
|
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: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
@@ -838,7 +878,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { chatId } = Params.parse(req.params);
|
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({
|
const msg = await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -866,7 +908,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
maxTokens: z.number().int().positive().optional(),
|
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
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -891,16 +935,29 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
const Body = z.object({
|
const Body = z
|
||||||
chatId: z.string().optional(),
|
.object({
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
chatId: z.string().optional(),
|
||||||
model: z.string().min(1),
|
persist: z.boolean().optional(),
|
||||||
messages: z.array(CompletionMessageSchema),
|
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
model: z.string().min(1),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
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
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -909,7 +966,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
// 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);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
482
web/src/App.tsx
482
web/src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
|
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||||
|
|
||||||
type ProviderModelPreferences = Record<Provider, string | null>;
|
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||||
|
|
||||||
|
type QuickQuestionModelSelection = {
|
||||||
|
provider: Provider;
|
||||||
|
modelPreferences: ProviderModelPreferences;
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
||||||
openai: null,
|
openai: null,
|
||||||
anthropic: 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) {
|
function pickProviderModel(options: string[], preferred: string | null) {
|
||||||
if (preferred?.trim()) return preferred.trim();
|
if (preferred?.trim()) return preferred.trim();
|
||||||
return options[0] ?? "";
|
return options[0] ?? "";
|
||||||
@@ -620,6 +657,22 @@ export default function App() {
|
|||||||
const stored = loadStoredModelPreferences();
|
const stored = loadStoredModelPreferences();
|
||||||
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -631,6 +684,7 @@ export default function App() {
|
|||||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||||
const searchRunAbortRef = useRef<AbortController | null>(null);
|
const searchRunAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
||||||
const searchRunCounterRef = useRef(0);
|
const searchRunCounterRef = useRef(0);
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const wasSendingRef = useRef(false);
|
const wasSendingRef = useRef(false);
|
||||||
@@ -713,6 +767,12 @@ export default function App() {
|
|||||||
setPendingChatState(null);
|
setPendingChatState(null);
|
||||||
setComposer("");
|
setComposer("");
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
|
setIsQuickQuestionOpen(false);
|
||||||
|
setQuickPrompt("");
|
||||||
|
setQuickSubmittedPrompt(null);
|
||||||
|
setQuickSubmittedModelSelection(null);
|
||||||
|
setQuickQuestionMessages([]);
|
||||||
|
setQuickQuestionError(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -846,6 +906,7 @@ export default function App() {
|
|||||||
}, [isAuthenticated, selectedItem]);
|
}, [isAuthenticated, selectedItem]);
|
||||||
|
|
||||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||||
|
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model.trim()) return;
|
if (model.trim()) return;
|
||||||
@@ -859,6 +920,46 @@ export default function App() {
|
|||||||
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
|
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
|
||||||
}, [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 selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||||
const isChatReplyStreamingInView =
|
const isChatReplyStreamingInView =
|
||||||
isSending &&
|
isSending &&
|
||||||
@@ -933,6 +1034,8 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
searchRunAbortRef.current?.abort();
|
searchRunAbortRef.current?.abort();
|
||||||
searchRunAbortRef.current = null;
|
searchRunAbortRef.current = null;
|
||||||
|
quickQuestionAbortRef.current?.abort();
|
||||||
|
quickQuestionAbortRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -960,6 +1063,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
||||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
}, [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(() => {
|
const selectedChatSummary = useMemo(() => {
|
||||||
if (!selectedItem || selectedItem.kind !== "chat") return null;
|
if (!selectedItem || selectedItem.kind !== "chat") return null;
|
||||||
@@ -1028,6 +1143,12 @@ export default function App() {
|
|||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenQuickQuestion = () => {
|
||||||
|
setQuickQuestionError(null);
|
||||||
|
setIsQuickQuestionOpen(true);
|
||||||
|
setIsMobileSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateSearch = () => {
|
const handleCreateSearch = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1068,6 +1189,15 @@ export default function App() {
|
|||||||
if (!hasPrimaryModifier || event.altKey) return;
|
if (!hasPrimaryModifier || event.altKey) return;
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "i" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
setQuickQuestionError(null);
|
||||||
|
setIsQuickQuestionOpen((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuickQuestionOpen) return;
|
||||||
|
|
||||||
if (key === "j") {
|
if (key === "j") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
@@ -1087,7 +1217,7 @@ export default function App() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [filteredSidebarItems, isAuthenticated]);
|
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1138,6 +1268,17 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [contextMenu]);
|
}, [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 = () => {
|
const handleOpenAttachmentPicker = () => {
|
||||||
fileInputRef.current?.click();
|
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 handleSend = async () => {
|
||||||
const content = composer.trim();
|
const content = composer.trim();
|
||||||
const attachments = pendingAttachments;
|
const attachments = pendingAttachments;
|
||||||
@@ -1683,13 +2000,30 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 px-3 pb-3">
|
<div className="space-y-3 px-3 pb-3">
|
||||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
<div className="flex gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||||
New chat
|
<Plus className="h-4 w-4" />
|
||||||
<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">
|
New chat
|
||||||
{primaryShortcutModifier} J
|
<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">
|
||||||
</span>
|
{primaryShortcutModifier} J
|
||||||
</Button>
|
</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}>
|
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
New search
|
New search
|
||||||
@@ -1961,6 +2295,136 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ type Props = {
|
|||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
status?: "completed" | "failed";
|
status?: "completed" | "failed";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: string | null;
|
||||||
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
@@ -26,10 +33,26 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
|||||||
|
|
||||||
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
||||||
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
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";
|
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
||||||
return `Ran tool '${toolName}'.`;
|
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) {
|
function getToolIconName(toolName: string | null | undefined) {
|
||||||
const lowered = toolName?.toLowerCase() ?? "";
|
const lowered = toolName?.toLowerCase() ?? "";
|
||||||
if (lowered.includes("search")) return "search";
|
if (lowered.includes("search")) return "search";
|
||||||
@@ -37,6 +60,27 @@ function getToolIconName(toolName: string | null | undefined) {
|
|||||||
return "generic";
|
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) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
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 iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||||
const isFailed = toolLogMetadata.status === "failed";
|
const isFailed = toolLogMetadata.status === "failed";
|
||||||
|
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||||
|
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||||
|
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="flex justify-start">
|
<div key={message.id} className="flex justify-start">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isFailed
|
||||||
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
|
? "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-cyan-950/18 text-cyan-100"
|
: "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
|
||||||
<span>{getToolSummary(message, toolLogMetadata)}</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked, Renderer } from "marked";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type MarkdownMode = "default" | "citationTokens";
|
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) {
|
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"] });
|
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,77 @@ textarea {
|
|||||||
word-break: break-word;
|
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 {
|
.md-content p + p {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,13 +148,20 @@ type CompletionResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CompletionStreamHandlers = {
|
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;
|
onToolCall?: (payload: ToolCallEvent) => void;
|
||||||
onDelta?: (payload: { text: string }) => void;
|
onDelta?: (payload: { text: string }) => void;
|
||||||
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||||
onError?: (payload: { message: string }) => 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 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;
|
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||||
@@ -210,10 +217,11 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
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", {
|
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ title }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
@@ -443,7 +451,8 @@ export async function runCompletion(body: {
|
|||||||
|
|
||||||
export async function runCompletionStream(
|
export async function runCompletionStream(
|
||||||
body: {
|
body: {
|
||||||
chatId: string;
|
chatId?: string | null;
|
||||||
|
persist?: boolean;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
|||||||
Reference in New Issue
Block a user