Compare commits

..

6 Commits

25 changed files with 3986 additions and 87 deletions

View File

@@ -112,6 +112,12 @@ Behavior notes:
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
- For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction.
- Available tool calls for chat: `web_search` and `fetch_url`.
- `web_search` uses Exa and returns ranked results with per-result summaries/snippets.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output.
- `anthropic` currently runs without server-managed tool calls.
## Searches
@@ -171,7 +177,8 @@ Search run notes:
"createdAt": "...",
"role": "system|user|assistant|tool",
"content": "...",
"name": null
"name": null,
"metadata": null
}
```

View File

@@ -37,8 +37,9 @@ Notes:
Event order:
1. Exactly one `meta`
2. Zero or more `delta`
3. Exactly one terminal event: `done` or `error`
2. Zero or more `tool_call`
3. Zero or more `delta`
4. Exactly one terminal event: `done` or `error`
### `meta`
@@ -60,6 +61,23 @@ Event order:
`text` may contain partial words, punctuation, or whitespace.
### `tool_call`
```json
{
"toolCallId": "call_123",
"name": "web_search",
"status": "completed",
"summary": "Performed web search for 'latest CPI release'.",
"args": { "query": "latest CPI release" },
"startedAt": "2026-03-02T10:00:00.000Z",
"completedAt": "2026-03-02T10:00:00.820Z",
"durationMs": 820,
"error": null,
"resultPreview": "{\"ok\":true,...}"
}
```
### `done`
```json
@@ -84,10 +102,15 @@ Event order:
## Provider Streaming Behavior
- `openai`: streamed via OpenAI chat completion chunks; emits `delta` from `choices[0].delta.content`.
- `xai`: uses OpenAI-compatible API, same chunk extraction as OpenAI.
- `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) before producing final text.
- `xai`: same tool-enabled behavior as OpenAI.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
Tool-enabled streaming notes (`openai`/`xai`):
- Stream still emits standard `meta`, `delta`, `done|error` events.
- Stream may emit `tool_call` events while tool calls are executed.
- `delta` events stream incrementally as text is generated.
## Persistence + Consistency Model
Backend database remains source of truth.

View File

@@ -125,6 +125,9 @@ actor SybilAPIClient {
case "meta":
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
await onEvent(.meta(payload))
case "tool_call":
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
await onEvent(.toolCall(payload))
case "delta":
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
await onEvent(.delta(payload))

View File

@@ -71,6 +71,10 @@ private struct MessageBubble: View {
var message: Message
var isSending: Bool
private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata
}
private var isUser: Bool {
message.role == .user
}
@@ -83,6 +87,12 @@ private struct MessageBubble: View {
var body: some View {
HStack {
if let toolCallMetadata {
ToolCallActivityChip(
metadata: toolCallMetadata,
fallbackContent: message.content
)
} else {
VStack(alignment: .leading, spacing: 8) {
if isPendingAssistant {
HStack(spacing: 8) {
@@ -124,3 +134,57 @@ private struct MessageBubble: View {
}
}
}
}
private struct ToolCallActivityChip: View {
var metadata: ToolCallMetadata
var fallbackContent: String
private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
return text
}
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent
}
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
}
private var iconName: String {
let name = (metadata.toolName ?? "").lowercased()
if name.contains("search") {
return "globe"
}
if name.contains("url") || name.contains("fetch") || name.contains("http") {
return "link"
}
return "wrench.and.screwdriver"
}
private var isFailed: Bool {
(metadata.status ?? "").lowercased() == "failed"
}
var body: some View {
HStack(spacing: 8) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary)
Text(summary)
.font(.caption)
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1)
)
)
.frame(maxWidth: 460, alignment: .leading)
}
}

View File

@@ -46,6 +46,102 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
public var role: MessageRole
public var content: String
public var name: String?
public var metadata: JSONValue? = nil
public var toolCallMetadata: ToolCallMetadata? {
guard role == .tool,
let object = metadata?.objectValue,
object["kind"]?.stringValue == "tool_call"
else { return nil }
return ToolCallMetadata(
toolCallId: object["toolCallId"]?.stringValue,
toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue,
summary: object["summary"]?.stringValue
)
}
public var isToolCallLog: Bool {
toolCallMetadata != nil
}
}
public struct ToolCallMetadata: Hashable, Sendable {
public var toolCallId: String?
public var toolName: String?
public var status: String?
public var summary: String?
}
public enum JSONValue: Codable, Hashable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
return
}
if let value = try? container.decode(Bool.self) {
self = .bool(value)
return
}
if let value = try? container.decode(Double.self) {
self = .number(value)
return
}
if let value = try? container.decode(String.self) {
self = .string(value)
return
}
if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
return
}
if let value = try? container.decode([JSONValue].self) {
self = .array(value)
return
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .string(value):
try container.encode(value)
case let .number(value):
try container.encode(value)
case let .bool(value):
try container.encode(value)
case let .object(value):
try container.encode(value)
case let .array(value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
public var stringValue: String? {
if case let .string(value) = self {
return value
}
return nil
}
public var objectValue: [String: JSONValue]? {
if case let .object(value) = self {
return value
}
return nil
}
}
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
@@ -153,12 +249,26 @@ public struct CompletionStreamDone: Codable, Sendable {
public var text: String
}
public struct CompletionStreamToolCall: Codable, Sendable {
public var toolCallId: String
public var name: String
public var status: String
public var summary: String
public var args: [String: JSONValue]
public var startedAt: String
public var completedAt: String
public var durationMs: Int
public var error: String?
public var resultPreview: String?
}
public struct StreamErrorPayload: Codable, Sendable {
public var message: String
}
public enum CompletionStreamEvent: Sendable {
case meta(CompletionStreamMeta)
case toolCall(CompletionStreamToolCall)
case delta(CompletionStreamDelta)
case done(CompletionStreamDone)
case error(StreamErrorPayload)

View File

@@ -693,7 +693,9 @@ final class SybilViewModel {
}
let requestMessages: [CompletionRequestMessage] =
baseChat.messages.map {
baseChat.messages
.filter { !$0.isToolCallLog }
.map {
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
} + [CompletionRequestMessage(role: .user, content: content)]
@@ -749,6 +751,9 @@ final class SybilViewModel {
case let .meta(payload):
pendingChatState?.chatID = payload.chatId
case let .toolCall(payload):
insertPendingToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutatePendingAssistantMessage { existing in
@@ -880,6 +885,51 @@ final class SybilViewModel {
pendingChatState = pending
}
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) {
guard var pending = pendingChatState else {
return
}
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
return
}
let metadata: JSONValue = .object([
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name),
"status": .string(payload.status),
"summary": .string(payload.summary),
"args": .object(payload.args),
"startedAt": .string(payload.startedAt),
"completedAt": .string(payload.completedAt),
"durationMs": .number(Double(payload.durationMs)),
"error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
])
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'."
: payload.summary
let message = Message(
id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(),
role: .tool,
content: summary,
name: payload.name,
metadata: metadata
)
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex)
} else {
pending.messages.append(message)
}
pendingChatState = pending
}
private var currentChatID: String? {
if draftKind == .chat {
return nil

168
server/package-lock.json generated
View File

@@ -18,6 +18,7 @@
"dotenv": "^17.2.3",
"exa-js": "^2.4.0",
"fastify": "^5.7.2",
"html-to-text": "^9.0.5",
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
@@ -852,6 +853,19 @@
"@prisma/debug": "6.6.0"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@types/node": {
"version": "25.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
@@ -996,6 +1010,15 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1014,6 +1037,61 @@
"node": ">=6"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -1035,6 +1113,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1353,6 +1443,41 @@
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1452,6 +1577,15 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1648,6 +1782,19 @@
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT"
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
@@ -1664,6 +1811,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
@@ -1882,6 +2038,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",

View File

@@ -26,6 +26,7 @@
"dotenv": "^17.2.3",
"exa-js": "^2.4.0",
"fastify": "^5.7.2",
"html-to-text": "^9.0.5",
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",

View File

@@ -0,0 +1,657 @@
import { convert as htmlToText } from "html-to-text";
import type OpenAI from "openai";
import { z } from "zod";
import { exaClient } from "../search/exa.js";
import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = 4;
const DEFAULT_WEB_RESULTS = 5;
const MAX_WEB_RESULTS = 10;
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
const MAX_FETCH_MAX_CHARACTERS = 50_000;
const FETCH_TIMEOUT_MS = 12_000;
const WebSearchArgsSchema = z
.object({
query: z.string().trim().min(1),
numResults: z.coerce.number().int().min(1).max(MAX_WEB_RESULTS).optional(),
type: z.enum(["auto", "fast", "instant"]).optional(),
includeDomains: z.array(z.string().trim().min(1)).max(25).optional(),
excludeDomains: z.array(z.string().trim().min(1)).max(25).optional(),
})
.strict();
const FetchUrlArgsSchema = z
.object({
url: z.string().trim().url(),
maxCharacters: z.coerce.number().int().min(500).max(MAX_FETCH_MAX_CHARACTERS).optional(),
})
.strict();
const CHAT_TOOLS: any[] = [
{
type: "function",
function: {
name: "web_search",
description:
"Search the public web for recent or factual information. Returns ranked results with per-result summaries and snippets.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query." },
numResults: {
type: "integer",
minimum: 1,
maximum: MAX_WEB_RESULTS,
description: "Number of results to return (default 5).",
},
type: {
type: "string",
enum: ["auto", "fast", "instant"],
description: "Search mode.",
},
includeDomains: {
type: "array",
items: { type: "string" },
description: "Only include these domains.",
},
excludeDomains: {
type: "array",
items: { type: "string" },
description: "Exclude these domains.",
},
},
required: ["query"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "fetch_url",
description:
"Fetch a webpage by URL and return readable plaintext content extracted from the page for deeper inspection.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "Absolute URL to fetch, including http/https." },
maxCharacters: {
type: "integer",
minimum: 500,
maximum: MAX_FETCH_MAX_CHARACTERS,
description: "Maximum response text characters returned (default 12000).",
},
},
required: ["url"],
additionalProperties: false,
},
},
},
];
export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " +
"Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " +
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
"Do not fabricate tool outputs; reason only from provided tool results.";
type ToolRunOutcome = {
ok: boolean;
[key: string]: unknown;
};
type ToolAwareUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
type ToolAwareCompletionResult = {
text: string;
usage?: ToolAwareUsage;
raw: unknown;
toolEvents: ToolExecutionEvent[];
};
export type ToolAwareStreamingEvent =
| { type: "delta"; text: string }
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "done"; result: ToolAwareCompletionResult };
type ToolAwareCompletionParams = {
client: OpenAI;
model: string;
messages: ChatMessage[];
temperature?: number;
maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
logContext?: {
provider: string;
model: string;
chatId?: string;
};
};
export type ToolExecutionEvent = {
toolCallId: string;
name: string;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
function compactWhitespace(input: string) {
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function clipText(input: string, maxCharacters: number) {
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
}
function toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function toSingleLine(value: string, maxLength = 220) {
return clipText(
value
.replace(/\r?\n+/g, " ")
.replace(/\s+/g, " ")
.trim(),
maxLength
);
}
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
if (name === "web_search") {
const query = typeof args.query === "string" ? args.query.trim() : "";
if (status === "completed") {
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
}
return query ? `Web search for '${toSingleLine(query, 100)}' failed.${errSuffix}` : `Web search failed.${errSuffix}`;
}
if (name === "fetch_url") {
const url = typeof args.url === "string" ? args.url.trim() : "";
if (status === "completed") {
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
}
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
}
if (status === "completed") {
return `Ran tool '${name}'.`;
}
return `Tool '${name}' failed.${errSuffix}`;
}
function logToolEvent(event: ToolExecutionEvent, context?: ToolAwareCompletionParams["logContext"]) {
const payload = {
kind: "tool_call",
...context,
...event,
};
const line = `[tool_call] ${JSON.stringify(payload)}`;
if (event.status === "failed") console.error(line);
else console.info(line);
}
function buildResultPreview(toolResult: ToolRunOutcome) {
const serialized = JSON.stringify(toolResult);
return serialized ? clipText(serialized, 400) : undefined;
}
export function buildToolLogMessageData(chatId: string, event: ToolExecutionEvent) {
return {
chatId,
role: "tool" as const,
content: event.summary,
name: event.name,
metadata: {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
},
};
}
function extractHtmlTitle(html: string) {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!match?.[1]) return null;
return compactWhitespace(
match[1]
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
);
}
function normalizeIncomingMessages(messages: ChatMessage[]) {
const normalized = messages.map((m) => {
if (m.role === "tool") {
const name = m.name?.trim() || "tool";
return {
role: "user",
content: `Tool output (${name}):\n${m.content}`,
};
}
if (m.role === "assistant" || m.role === "system" || m.role === "user") {
const out: any = { role: m.role, content: m.content };
if (m.name && (m.role === "assistant" || m.role === "user")) {
out.name = m.name;
}
return out;
}
return { role: "user", content: m.content };
});
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
const exa = exaClient();
const response = await exa.search(args.query, {
type: args.type ?? "auto",
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
includeDomains: args.includeDomains,
excludeDomains: args.excludeDomains,
moderation: true,
userLocation: "US",
contents: {
summary: { query: args.query },
highlights: {
query: args.query,
maxCharacters: 320,
numSentences: 2,
highlightsPerUrl: 2,
},
text: { maxCharacters: 1_000 },
},
} as any);
const results = Array.isArray(response?.results) ? response.results : [];
return {
ok: true,
query: args.query,
requestId: response?.requestId ?? null,
results: results.map((result: any, index: number) => ({
rank: index + 1,
title: typeof result?.title === "string" ? result.title : null,
url: typeof result?.url === "string" ? result.url : null,
publishedDate: typeof result?.publishedDate === "string" ? result.publishedDate : null,
author: typeof result?.author === "string" ? result.author : null,
summary: typeof result?.summary === "string" ? clipText(result.summary, 1_400) : null,
text: typeof result?.text === "string" ? clipText(result.text, 700) : null,
highlights: Array.isArray(result?.highlights)
? result.highlights.filter((h: unknown) => typeof h === "string").slice(0, 3).map((h: string) => clipText(h, 280))
: [],
})),
};
}
function assertSafeFetchUrl(urlRaw: string) {
const parsed = new URL(urlRaw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Only http:// and https:// URLs are supported.");
}
return parsed;
}
async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
const args = FetchUrlArgsSchema.parse(input);
const parsed = assertSafeFetchUrl(args.url);
const maxCharacters = args.maxCharacters ?? DEFAULT_FETCH_MAX_CHARACTERS;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(parsed.toString(), {
redirect: "follow",
signal: controller.signal,
headers: {
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
},
});
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}.`);
}
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
const body = await response.text();
const isHtml = contentType.includes("text/html") || /<!doctype html|<html[\s>]/i.test(body);
let extracted = body;
if (isHtml) {
extracted = htmlToText(body, {
wordwrap: false,
preserveNewlines: true,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "script", format: "skip" },
{ selector: "style", format: "skip" },
{ selector: "noscript", format: "skip" },
{ selector: "a", options: { ignoreHref: true } },
],
});
}
const normalized = compactWhitespace(extracted);
const truncated = normalized.length > maxCharacters;
const text = truncated
? `${normalized.slice(0, maxCharacters)}\n\n[truncated ${normalized.length - maxCharacters} characters]`
: normalized;
return {
ok: true,
url: response.url || parsed.toString(),
status: response.status,
contentType: contentType || null,
title: isHtml ? extractHtmlTitle(body) : null,
truncated,
text,
};
}
async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome> {
if (name === "web_search") return runWebSearchTool(args);
if (name === "fetch_url") return runFetchUrlTool(args);
return { ok: false, error: `Unknown tool: ${name}` };
}
function parseToolArgs(raw: unknown) {
if (typeof raw !== "string") return {};
const trimmed = raw.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch (err: any) {
throw new Error(`Invalid JSON arguments: ${err?.message ?? String(err)}`);
}
}
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
if (!usage) return false;
acc.inputTokens += usage.prompt_tokens ?? 0;
acc.outputTokens += usage.completion_tokens ?? 0;
acc.totalTokens += usage.total_tokens ?? 0;
return true;
}
type NormalizedToolCall = {
id: string;
name: string;
arguments: string;
};
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
return toolCalls.map((call: any, index: number) => ({
id: call?.id ?? `tool_call_${round}_${index}`,
name: call?.function?.name ?? "unknown_tool",
arguments: call?.function?.arguments ?? "{}",
}));
}
async function executeToolCallAndBuildEvent(
call: NormalizedToolCall,
params: ToolAwareCompletionParams
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
let toolResult: ToolRunOutcome;
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = toRecord(parseToolArgs(call.arguments));
toolResult = await executeTool(call.name, parsedArgs);
} catch (err: any) {
toolResult = {
ok: false,
error: err?.message ?? String(err),
};
}
const status: "completed" | "failed" = toolResult.ok ? "completed" : "failed";
const error =
status === "failed"
? typeof toolResult.error === "string"
? toolResult.error
: "Tool execution failed."
: undefined;
const completedAtMs = Date.now();
const event: ToolExecutionEvent = {
toolCallId: call.id,
name: call.name,
status,
summary: buildToolSummary(call.name, parsedArgs, status, error),
args: parsedArgs,
startedAt,
completedAt: new Date(completedAtMs).toISOString(),
durationMs: completedAtMs - startedAtMs,
error,
resultPreview: buildResultPreview(toolResult),
};
logToolEvent(event, params.logContext);
if (params.onToolEvent) {
await params.onToolEvent(event);
}
return { event, toolResult };
}
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tool_choice: "auto",
} as any);
rawResponses.push(completion);
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
const message = completion?.choices?.[0]?.message;
if (!message) {
return {
text: "",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
toolEvents,
};
}
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (!toolCalls.length) {
return {
text: typeof message.content === "string" ? message.content : "",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
};
}
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
totalToolCalls += normalizedToolCalls.length;
const assistantToolCallMessage: any = {
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
};
if (typeof message.content === "string" && message.content.length) {
assistantToolCallMessage.content = message.content;
}
conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
return {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
};
}
export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const stream = await params.client.chat.completions.create({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tool_choice: "auto",
stream: true,
stream_options: { include_usage: true },
} as any);
let roundText = "";
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
for await (const chunk of stream as any as AsyncIterable<any>) {
rawResponses.push(chunk);
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
const choice = chunk?.choices?.[0];
const deltaText = choice?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
roundText += deltaText;
if (roundToolCalls.size === 0) {
yield { type: "delta", text: deltaText };
}
}
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
for (const toolCall of deltaToolCalls) {
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
if (typeof toolCall?.id === "string" && toolCall.id.length) {
entry.id = toolCall.id;
}
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
entry.name = toolCall.function.name;
}
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
entry.arguments += toolCall.function.arguments;
}
roundToolCalls.set(idx, entry);
}
}
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
.sort((a, b) => a[0] - b[0])
.map(([_, call], index) => ({
id: call.id ?? `tool_call_${round}_${index}`,
name: call.name ?? "unknown_tool",
arguments: call.arguments || "{}",
}));
if (!normalizedToolCalls.length) {
yield {
type: "done",
result: {
text: roundText,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
});
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
yield { type: "tool_call", event };
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
yield {
type: "done",
result: {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
},
};
}

View File

@@ -1,6 +1,7 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareOpenAIChat } from "./chat-tools.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) {
@@ -44,25 +45,26 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let outText = "";
let usage: MultiplexResponse["usage"] | undefined;
let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
const r = await client.chat.completions.create({
const r = await runToolAwareOpenAIChat({
client,
model: req.model,
// OpenAI SDK has very specific message union types; our normalized schema is compatible.
messages: req.messages.map((m) => ({ role: m.role, content: m.content, name: m.name })) as any,
messages: req.messages,
temperature: req.temperature,
max_tokens: req.maxTokens,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
});
raw = r;
outText = r.choices?.[0]?.message?.content ?? "";
usage = r.usage
? {
inputTokens: r.usage.prompt_tokens,
outputTokens: r.usage.completion_tokens,
totalTokens: r.usage.total_tokens,
}
: undefined;
raw = r.raw;
outText = r.text;
usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "anthropic") {
const client = anthropicClient();
@@ -100,16 +102,27 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
const latencyMs = Math.round(performance.now() - t0);
// Store assistant message + call record
await prisma.$transaction([
prisma.message.create({
// Store tool activity (if any), assistant message, and call record.
await prisma.$transaction(async (tx) => {
if (toolMessages.length) {
await tx.message.createMany({
data: toolMessages.map((message) => ({
chatId: message.chatId,
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.metadata as any,
})),
});
}
await tx.message.create({
data: {
chatId: call.chatId,
role: "assistant" as any,
content: outText,
},
}),
prisma.llmCall.update({
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
@@ -118,8 +131,8 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
}),
]);
});
});
return {
provider: req.provider,

View File

@@ -1,11 +1,12 @@
import { performance } from "node:perf_hooks";
import type OpenAI from "openai";
import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareOpenAIChatStream, type ToolExecutionEvent } from "./chat-tools.js";
import type { MultiplexRequest, Provider } from "./types.js";
export type StreamEvent =
| { type: "meta"; chatId: string; callId: string; 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: "error"; message: string };
@@ -51,28 +52,39 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
let text = "";
let usage: StreamEvent extends any ? any : never;
let raw: unknown = { streamed: true };
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
try {
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
const stream = await client.chat.completions.create({
for await (const ev of runToolAwareOpenAIChatStream({
client,
model: req.model,
messages: req.messages.map((m) => ({ role: m.role, content: m.content, name: m.name })) as any,
messages: req.messages,
temperature: req.temperature,
max_tokens: req.maxTokens,
stream: true,
});
for await (const chunk of stream as any as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>) {
const delta = chunk.choices?.[0]?.delta?.content ?? "";
if (delta) {
text += delta;
yield { type: "delta", text: delta };
}
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
})) {
if (ev.type === "delta") {
text += ev.text;
yield { type: "delta", text: ev.text };
continue;
}
// no guaranteed usage in stream mode across providers; leave empty for now
if (ev.type === "tool_call") {
toolMessages.push(buildToolLogMessageData(chatId, ev.event));
yield { type: "tool_call", event: ev.event };
continue;
}
raw = ev.result.raw;
usage = ev.result.usage;
text = ev.result.text;
}
} else if (req.provider === "anthropic") {
const client = anthropicClient();
@@ -110,17 +122,29 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
}
// some streams end with message_stop
}
raw = { streamed: true, provider: "anthropic" };
} else {
throw new Error(`unknown provider: ${req.provider}`);
}
const latencyMs = Math.round(performance.now() - t0);
await prisma.$transaction([
prisma.message.create({
await prisma.$transaction(async (tx) => {
if (toolMessages.length) {
await tx.message.createMany({
data: toolMessages.map((message) => ({
chatId: message.chatId,
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.metadata as any,
})),
});
}
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
}),
prisma.llmCall.update({
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
@@ -129,8 +153,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
}),
]);
});
});
yield { type: "done", text, usage };
} catch (e: any) {

View File

@@ -16,10 +16,23 @@ type IncomingChatMessage = {
name?: string;
};
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
function sameMessage(
a: { role: string; content: string; name?: string | null },
b: { role: string; content: string; name?: string | null }
) {
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
}
function isToolCallLogMetadata(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return record.kind === "tool_call";
}
function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return;
@@ -27,13 +40,13 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
const existing = await prisma.message.findMany({
where: { chatId },
orderBy: { createdAt: "asc" },
select: { role: true, content: true, name: true },
select: { role: true, content: true, name: true, metadata: true },
});
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
let sharedPrefix = 0;
const max = Math.min(existingNonAssistant.length, incoming.length);
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix], incoming[sharedPrefix])) {
sharedPrefix += 1;
}
@@ -748,6 +761,7 @@ export async function registerRoutes(app: FastifyInstance) {
for await (const ev of runMultiplexStream(body)) {
if (ev.type === "meta") send("meta", ev);
else if (ev.type === "tool_call") send("tool_call", ev.event);
else if (ev.type === "delta") send("delta", ev);
else if (ev.type === "done") send("done", ev);
else if (ev.type === "error") send("error", ev);

3
server/src/types/html-to-text.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "html-to-text" {
export function convert(html: string, options?: unknown): string;
}

2
tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

49
tui/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Sybil TUI
Terminal UI client for Sybil with a sidebar + workspace flow similar to the web app.
## Setup
```bash
cd tui
npm install
npm run dev
```
Build/start:
```bash
npm run build
npm run start
```
## Environment Variables
Configuration is environment-only (no in-app settings).
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
Compatibility aliases:
- `SYBIL_API_BASE_URL` (fallback for API URL)
- `SYBIL_ADMIN_TOKEN` (fallback for token)
## Key Bindings
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
- `Esc` (in composer): exit input mode and focus sidebar
- `Up` / `Down` (in sidebar): move highlight
- `Page Up` / `Page Down` (in transcript): scroll the transcript by one page
- `Enter` in sidebar: load highlighted conversation/search
- `Enter` in composer: send message/search
- `n`: new chat draft
- `/`: new search draft
- `d`: delete selected chat/search
- `p`: cycle provider (chat mode)
- `m`: cycle model (chat mode)
- `r`: refresh collections + models
- `q` or `Ctrl+C`: quit

616
tui/package-lock.json generated Normal file
View File

@@ -0,0 +1,616 @@
{
"name": "sybil-tui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sybil-tui",
"version": "0.1.0",
"dependencies": {
"blessed": "^0.1.81"
},
"devDependencies": {
"@types/blessed": "^0.1.25",
"@types/node": "^25.0.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/blessed": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz",
"integrity": "sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
"license": "MIT",
"bin": {
"blessed": "bin/tput.js"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}

21
tui/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "sybil-tui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"blessed": "^0.1.81"
},
"devDependencies": {
"@types/blessed": "^0.1.25",
"@types/node": "^25.0.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

272
tui/src/api.ts Normal file
View File

@@ -0,0 +1,272 @@
import type {
ChatDetail,
ChatSummary,
CompletionRequestMessage,
CompletionStreamHandlers,
ModelCatalogResponse,
Provider,
SearchDetail,
SearchRunRequest,
SearchStreamHandlers,
SearchSummary,
SessionStatus,
} from "./types.js";
type RequestOptions = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: unknown;
signal?: AbortSignal;
headers?: Record<string, string>;
};
export class SybilApiClient {
private readonly baseUrl: string;
private readonly token: string | null;
constructor(baseUrl: string, token: string | null) {
this.baseUrl = baseUrl;
this.token = token;
}
async verifySession() {
return this.request<SessionStatus>("/v1/auth/session");
}
async listModels() {
return this.request<ModelCatalogResponse>("/v1/models");
}
async listChats() {
const data = await this.request<{ chats: ChatSummary[] }>("/v1/chats");
return data.chats;
}
async createChat(title?: string) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: { title },
});
return data.chat;
}
async getChat(chatId: string) {
const data = await this.request<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
body,
});
return data.chat;
}
async deleteChat(chatId: string) {
await this.request<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
}
async listSearches() {
const data = await this.request<{ searches: SearchSummary[] }>("/v1/searches");
return data.searches;
}
async createSearch(body?: { title?: string; query?: string }) {
const data = await this.request<{ search: SearchSummary }>("/v1/searches", {
method: "POST",
body: body ?? {},
});
return data.search;
}
async getSearch(searchId: string) {
const data = await this.request<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
return data.search;
}
async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
async runCompletionStream(
body: {
chatId: string;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }
) {
await this.runSse(
"/v1/chat-completions/stream",
body,
{
meta: handlers.onMeta,
tool_call: handlers.onToolCall,
delta: handlers.onDelta,
done: handlers.onDone,
error: handlers.onError,
},
options
);
}
async runSearchStream(
searchId: string,
body: SearchRunRequest,
handlers: SearchStreamHandlers,
options?: { signal?: AbortSignal }
) {
await this.runSse(
`/v1/searches/${searchId}/run/stream`,
body,
{
search_results: handlers.onSearchResults,
search_error: handlers.onSearchError,
answer: handlers.onAnswer,
answer_error: handlers.onAnswerError,
done: handlers.onDone,
error: handlers.onError,
},
options
);
}
private async request<T>(path: string, options?: RequestOptions): Promise<T> {
const headers = new Headers(options?.headers ?? {});
const hasBody = options?.body !== undefined;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (this.token) {
headers.set("Authorization", `Bearer ${this.token}`);
}
const init: RequestInit = {
method: options?.method ?? "GET",
headers,
};
if (hasBody) {
init.body = JSON.stringify(options?.body);
}
if (options?.signal) {
init.signal = options.signal;
}
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
throw new Error(await this.readErrorMessage(response));
}
return (await response.json()) as T;
}
private async runSse(
path: string,
body: unknown,
handlers: Record<string, ((payload: any) => void) | undefined>,
options?: { signal?: AbortSignal }
) {
const headers = new Headers({
Accept: "text/event-stream",
"Content-Type": "application/json",
});
if (this.token) {
headers.set("Authorization", `Bearer ${this.token}`);
}
const init: RequestInit = {
method: "POST",
headers,
body: JSON.stringify(body),
};
if (options?.signal) {
init.signal = options.signal;
}
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
throw new Error(await this.readErrorMessage(response));
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
handlers[eventName]?.(payload);
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
}
private async readErrorMessage(response: Response) {
const fallback = `${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === "string" && body.message.trim()) {
return body.message;
}
return fallback;
} catch {
return fallback;
}
}
}

48
tui/src/config.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
function normalizeBaseUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("SYBIL_TUI_API_BASE_URL cannot be empty");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`Invalid SYBIL_TUI_API_BASE_URL: ${trimmed}`);
}
const normalizedPath = parsed.pathname.replace(/\/+$/, "");
parsed.pathname = normalizedPath || "/";
return parsed.toString().replace(/\/$/, "");
}
function parseProvider(value: string | undefined): Provider {
const trimmed = value?.trim().toLowerCase();
if (!trimmed) return "openai";
if (PROVIDERS.includes(trimmed as Provider)) return trimmed as Provider;
throw new Error(`Invalid SYBIL_TUI_DEFAULT_PROVIDER: ${value}`);
}
function parsePositiveInt(value: string | undefined, fallback: number) {
if (!value?.trim()) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid positive integer value: ${value}`);
}
return parsed;
}
const apiBaseUrlValue =
process.env.SYBIL_TUI_API_BASE_URL?.trim() || process.env.SYBIL_API_BASE_URL?.trim() || "http://127.0.0.1:8787";
export const config = {
apiBaseUrl: normalizeBaseUrl(apiBaseUrlValue),
adminToken: process.env.SYBIL_TUI_ADMIN_TOKEN?.trim() || process.env.SYBIL_ADMIN_TOKEN?.trim() || null,
defaultProvider: parseProvider(process.env.SYBIL_TUI_DEFAULT_PROVIDER),
defaultModel: process.env.SYBIL_TUI_DEFAULT_MODEL?.trim() || null,
searchNumResults: parsePositiveInt(process.env.SYBIL_TUI_SEARCH_NUM_RESULTS, 10),
};

1450
tui/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

140
tui/src/types.ts Normal file
View File

@@ -0,0 +1,140 @@
export type Provider = "openai" | "anthropic" | "xai";
export type ProviderModelInfo = {
models: string[];
loadedAt: string | null;
error: string | null;
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
};
export type ChatSummary = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type SearchSummary = {
id: string;
title: string | null;
query: string | null;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
createdAt: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
name: string | null;
metadata: unknown | null;
};
export type ToolCallEvent = {
toolCallId: string;
name: string;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
export type ChatDetail = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
messages: Message[];
};
export type SearchResultItem = {
id: string;
createdAt: string;
rank: number;
title: string | null;
url: string;
publishedDate: string | null;
author: string | null;
text: string | null;
highlights: string[] | null;
highlightScores: number[] | null;
score: number | null;
favicon: string | null;
image: string | null;
};
export type SearchDetail = {
id: string;
title: string | null;
query: string | null;
createdAt: string;
updatedAt: string;
requestId: string | null;
latencyMs: number | null;
error: string | null;
answerText: string | null;
answerRequestId: string | null;
answerCitations: Array<{
id?: string;
url?: string;
title?: string | null;
publishedDate?: string | null;
author?: string | null;
text?: string | null;
}> | null;
answerError: string | null;
results: SearchResultItem[];
};
export type SearchRunRequest = {
query?: string;
title?: string;
type?: "auto" | "fast" | "deep" | "instant";
numResults?: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
};
export type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; 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;
};
export type SearchStreamHandlers = {
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
onSearchError?: (payload: { error: string }) => void;
onAnswer?: (payload: { answerText: string | null; answerRequestId: string | null; answerCitations: SearchDetail["answerCitations"] }) => void;
onAnswerError?: (payload: { error: string }) => void;
onDone?: (payload: { search: SearchDetail }) => void;
onError?: (payload: { message: string }) => void;
};
export type SessionStatus = {
authenticated: true;
mode: "open" | "token";
};

16
tui/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -27,6 +27,7 @@ import {
type Message,
type SearchDetail,
type SearchSummary,
type ToolCallEvent,
} from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils";
@@ -139,6 +140,54 @@ function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "las
};
}
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 {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function isToolCallLogMessage(message: Message) {
return asToolLogMetadata(message.metadata) !== null;
}
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return {
id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
metadata: {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
};
}
type ModelComboboxProps = {
options: string[];
value: string;
@@ -707,6 +756,7 @@ export default function App() {
role: "user",
content,
name: null,
metadata: null,
};
const optimisticAssistantMessage: Message = {
@@ -715,6 +765,7 @@ export default function App() {
role: "assistant",
content: "",
name: null,
metadata: null,
};
setPendingChatState({
@@ -758,7 +809,9 @@ export default function App() {
}
const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages.map((message) => ({
...baseChat.messages
.filter((message) => !isToolCallLogMessage(message))
.map((message) => ({
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
@@ -813,6 +866,35 @@ export default function App() {
if (payload.chatId !== chatId) return;
setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current));
},
onToolCall: (payload) => {
setPendingChatState((current) => {
if (!current) return current;
if (
current.messages.some(
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
)
) {
return current;
}
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = current.messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) {
return { ...current, messages: current.messages.concat(toolMessage) };
}
return {
...current,
messages: [
...current.messages.slice(0, assistantIndex),
toolMessage,
...current.messages.slice(assistantIndex),
],
};
});
},
onDelta: (payload) => {
if (!payload.text) return;
setPendingChatState((current) => {

View File

@@ -1,6 +1,7 @@
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Globe2, Link2, Wrench } from "lucide-preact";
type Props = {
messages: Message[];
@@ -8,6 +9,33 @@ type Props = {
isSending: boolean;
};
type ToolLogMetadata = {
kind: "tool_call";
toolName?: string;
status?: "completed" | "failed";
summary?: string;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
if (lowered.includes("url") || lowered.includes("fetch") || lowered.includes("http")) return "fetch";
return "generic";
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
@@ -16,6 +44,28 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => {
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const isFailed = toolLogMetadata.status === "failed";
return (
<div key={message.id} className="flex justify-start">
<div
className={cn(
"inline-flex max-w-[85%] items-center gap-2 rounded-md border px-3 py-2 text-xs leading-5",
isFailed
? "border-rose-500/40 bg-rose-950/20 text-rose-200"
: "border-cyan-500/35 bg-cyan-950/20 text-cyan-100"
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{getToolSummary(message, toolLogMetadata)}</span>
</div>
</div>
);
}
const isUser = message.role === "user";
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
return (

View File

@@ -23,6 +23,20 @@ export type Message = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name: string | null;
metadata: unknown | null;
};
export type ToolCallEvent = {
toolCallId: string;
name: string;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
export type ChatDetail = {
@@ -113,6 +127,7 @@ type CompletionResponse = {
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; 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;
@@ -415,6 +430,7 @@ export async function runCompletionStream(
}
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);