65 Commits

Author SHA1 Message Date
7d69cb4979 Trying to add spacer at the end of the transcript 2026-05-06 22:24:04 -07:00
12b3d8c5ad ios: get rid of "assistant is typing" 2026-05-06 21:56:19 -07:00
bd0200ac98 ios: quick question UI 2026-05-06 21:53:51 -07:00
0c9b4d1ed3 web: better overscroll behavior 2026-05-06 01:35:12 -07:00
30656842a7 web: nicer code blocks 2026-05-04 22:18:33 -07:00
8b580fd3e1 add hermes agent provider 2026-05-04 21:52:39 -07:00
195e157e1a ios: sidebar swipe 2026-05-04 21:07:55 -07:00
c5dbd12587 ios: unify sidebars 2026-05-04 20:19:58 -07:00
be072fd46d ios: add multi-polling support 2026-05-04 20:14:16 -07:00
f514c42de6 backend, web: support for resuming streams 2026-05-04 09:12:31 -07:00
70a60edf1c ios: enable shitty copy from transcript 2026-05-03 23:26:58 -07:00
91ef28bf29 ios: more ambitious gestures / navigation 2026-05-03 23:06:39 -07:00
bb713f8806 original assets 2026-05-03 22:13:43 -07:00
e6cf344527 ios: appearance tweaks 2026-05-03 22:11:29 -07:00
4bc0773d35 ios: fix keyboard behavior 2026-05-03 21:52:49 -07:00
d1140d21d4 more consistent view model display between switching chats 2026-05-03 21:42:28 -07:00
0c0226e37e web: change font 2026-05-03 21:30:54 -07:00
0b94d5b3fa ios: invert scroll view technique 2026-05-03 21:21:03 -07:00
aff2531bf3 ios: fix new chat swipe 2026-05-03 21:14:10 -07:00
ee8a93a8c4 redesign bottom bar 2026-05-03 21:06:20 -07:00
53a3b722ec ios: some iPad fixes 2026-05-03 18:38:16 -07:00
ae783020ef character opacity tweak 2026-05-03 18:15:13 -07:00
39acefb55a add Character animations 2026-05-03 18:11:53 -07:00
e6fe63280a ios: redesign top navbar 2026-05-03 17:52:57 -07:00
2403dd99ae web: busy character animation 2026-05-03 17:52:50 -07:00
89bd418566 web: idle character animation 2026-05-03 17:00:45 -07:00
e02168854c ios: better network handling 2026-05-03 16:42:49 -07:00
3820007289 web: css: better indenting with nested lists 2026-05-03 16:03:19 -07:00
5d046ca173 ios: dismiss kb on submit 2026-05-03 15:59:27 -07:00
bca408c971 ios: specify product name 2026-05-03 00:17:08 -07:00
2f265fd847 ios: mac catalyst kb shortcuts 2026-05-02 23:50:51 -07:00
29e340fd08 quick question feature 2026-05-02 23:48:01 -07:00
6fbcaecbf8 sidebar: move settings 2026-05-02 23:20:32 -07:00
519ebd15dd ios: mac catalyst target 2026-05-02 23:18:00 -07:00
8051dd2c71 web: nicer tables 2026-05-02 23:17:52 -07:00
2313e560e8 fix streaming 2026-05-02 23:09:39 -07:00
94565298d8 better new chat animation 2026-05-02 22:51:59 -07:00
7360604136 ios: swipe to create new conversation 2026-05-02 22:46:25 -07:00
ca6b5e0807 web: keyboard shortcuts 2026-05-02 22:45:15 -07:00
4b0cc3fbf7 ios: better fix for scroll 2026-05-02 22:25:24 -07:00
2da73f802c ios: scroll without animation when clicking 2026-05-02 22:21:18 -07:00
4ad36d9bf6 ios: better backgrounding/resume 2026-05-02 22:18:45 -07:00
cf9832ca3b tool call in-flight resume 2026-05-02 22:03:43 -07:00
2c32ca66e2 codex no sandbox (its already sandboxed) 2026-05-02 21:50:17 -07:00
015253c0af oai responses api, tool call retries 2026-05-02 21:44:32 -07:00
8d6c069a33 Various fixes for tool calling 2026-05-02 21:19:52 -07:00
d579b5bf75 adds shell tool 2026-05-02 19:52:19 -07:00
01ee807991 ios: adds file uploading 2026-05-02 19:47:47 -07:00
fd9ee455fb experimental devbox support 2026-05-02 19:38:15 -07:00
38da3cea72 adds attachment support 2026-05-02 19:21:06 -07:00
11e6875de9 ios: proj bump 2026-05-02 18:26:20 -07:00
5a690b276f web: transcript improvements 2026-05-02 18:25:20 -07:00
d7967eaa75 Adds searxng support for tool calling 2026-05-02 18:15:14 -07:00
2125c5dfa4 web: tweak scrolling behavior 2026-05-02 18:15:14 -07:00
815655a73c ios: fix api endpoint change 2026-05-02 18:09:22 -07:00
b85409d977 ios: xcode proj changes 2026-05-02 17:40:41 -07:00
d9f27213e7 Merge pull request 'Add chat flow for search results' (#8) from codex/chat-with-search-results into master
Reviewed-on: #8
2026-05-03 00:23:30 +00:00
3a6c40cb3c Merge pull request 'ios: right-align user bubbles on iPad' (#7) from codex/ios-right-align-user-bubbles into master
Reviewed-on: #7
2026-05-03 00:20:22 +00:00
188c460826 ios: use editor toolbar title 2026-05-02 17:19:34 -07:00
90278020f5 ios: pin Sybil navigation theme 2026-05-02 17:10:32 -07:00
cafe4bb9ae ios: unify iPad chat toolbar 2026-05-02 17:03:02 -07:00
57a6287b2b Merge pull request 'ios: stop refocusing composer after send' (#5) from codex/ios-no-autofocus-after-send into master
Reviewed-on: #5
2026-05-02 23:53:32 +00:00
dc9336acf9 add chat flow for search results 2026-05-02 16:48:01 -07:00
ba6fc9c660 ios: right-align user bubbles on iPad 2026-05-02 16:40:50 -07:00
85f8d6b5f3 ios: stop refocusing composer after send 2026-05-02 16:37:55 -07:00
71 changed files with 11419 additions and 1407 deletions

View File

@@ -24,6 +24,10 @@ COPY server/package.json server/package-lock.json ./
COPY server/scripts ./scripts
COPY server/prisma ./prisma
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN npm ci --omit=dev --no-audit --no-fund
COPY --from=server-build /app/server/dist ./dist

1
dist/default.conf vendored
View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
client_max_body_size 32m;
root /usr/share/nginx/html;
index index.html;

View File

@@ -12,9 +12,28 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
EXA_API_KEY: ${EXA_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
CHAT_CODEX_TOOL_ENABLED: ${CHAT_CODEX_TOOL_ENABLED:-false}
CHAT_CODEX_REMOTE_HOST: ${CHAT_CODEX_REMOTE_HOST:-}
CHAT_CODEX_REMOTE_USER: ${CHAT_CODEX_REMOTE_USER:-}
CHAT_CODEX_REMOTE_PORT: ${CHAT_CODEX_REMOTE_PORT:-22}
CHAT_CODEX_REMOTE_WORKDIR: ${CHAT_CODEX_REMOTE_WORKDIR:-/workspace/sybil-codex}
# Prefer mounting a private key read-only and pointing CHAT_CODEX_SSH_KEY_PATH at it.
CHAT_CODEX_SSH_KEY_PATH: ${CHAT_CODEX_SSH_KEY_PATH:-}
CHAT_CODEX_SSH_PRIVATE_KEY_B64: ${CHAT_CODEX_SSH_PRIVATE_KEY_B64:-}
CHAT_CODEX_EXEC_TIMEOUT_MS: ${CHAT_CODEX_EXEC_TIMEOUT_MS:-600000}
CHAT_SHELL_TOOL_ENABLED: ${CHAT_SHELL_TOOL_ENABLED:-false}
CHAT_SHELL_EXEC_TIMEOUT_MS: ${CHAT_SHELL_EXEC_TIMEOUT_MS:-120000}
volumes:
- sybil_data:/data
# Example key mount for codex_exec:
# - ./secrets/devbox_id_ed25519:/run/secrets/codex_ssh_key:ro
expose:
- "8787"
restart: unless-stopped

View File

@@ -10,6 +10,12 @@ Content type:
- Requests with bodies use `application/json`.
- Responses are JSON unless noted otherwise.
Chat upload limits:
- Chat completion and direct message payloads support inline attachments up to a 32 MB request body.
- Up to 8 attachments per message.
- Image attachments: PNG or JPEG only, max 6 MB each.
- Text attachments: up to 8 MB source size each; server accepts at most 200,000 characters of inlined text content per attachment.
## Health + Auth
### `GET /health`
@@ -27,10 +33,29 @@ Content type:
"providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
}
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
## Active Runs
### `GET /v1/active-runs`
- Response:
```json
{
"chats": ["chat-id-with-active-stream"],
"searches": ["search-id-with-active-stream"]
}
```
Behavior notes:
- Lists in-memory chat/search streams that are still running on this server process.
- Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts.
## Chats
@@ -38,9 +63,29 @@ Content type:
- Response: `{ "chats": ChatSummary[] }`
### `POST /v1/chats`
- Body: `{ "title"?: string }`
- Body:
```json
{
"title": "optional title",
"provider": "optional openai|anthropic|xai|hermes-agent",
"model": "optional model id",
"messages": [
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": []
}
]
}
```
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- `provider` and `model` must be supplied together when present.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
@@ -74,11 +119,34 @@ Behavior notes:
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"metadata": {}
"metadata": {},
"attachments": [
{
"kind": "image",
"id": "attachment-id",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 12345,
"dataUrl": "data:image/jpeg;base64,..."
},
{
"kind": "text",
"id": "attachment-id",
"filename": "notes.md",
"mimeType": "text/markdown",
"sizeBytes": 4567,
"text": "# Notes\\n...",
"truncated": false
}
]
}
```
- Response: `{ "message": Message }`
Notes:
- `attachments` is optional and is merged into stored `message.metadata.attachments`.
- Tool messages should not include attachments.
## Chat Completions (non-streaming)
### `POST /v1/chat-completions`
@@ -86,10 +154,33 @@ Behavior notes:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai",
"provider": "openai|anthropic|xai|hermes-agent",
"model": "string",
"messages": [
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": [
{
"kind": "image",
"id": "attachment-id",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 12345,
"dataUrl": "data:image/jpeg;base64,..."
},
{
"kind": "text",
"id": "attachment-id",
"filename": "notes.md",
"mimeType": "text/markdown",
"sizeBytes": 4567,
"text": "# Notes\\n...",
"truncated": false
}
]
}
],
"temperature": 0.2,
"maxTokens": 256
@@ -112,11 +203,34 @@ 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.
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
- `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.
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
- Devbox tool configuration:
- `CHAT_MAX_TOOL_ROUNDS=100` (optional; maximum model/tool result cycles before the backend returns a limit message)
- `CHAT_CODEX_TOOL_ENABLED=true`
- `CHAT_SHELL_TOOL_ENABLED=true`
- `CHAT_CODEX_REMOTE_HOST=<host-or-ip>` (required when enabled)
- `CHAT_CODEX_REMOTE_USER=<ssh-user>` (optional; omitted if `CHAT_CODEX_REMOTE_HOST` already contains `user@host`)
- `CHAT_CODEX_REMOTE_PORT=22` (optional)
- `CHAT_CODEX_REMOTE_WORKDIR=/workspace/sybil-codex` (optional; created on the remote host if missing)
- `CHAT_CODEX_SSH_KEY_PATH=/run/secrets/codex_ssh_key` (recommended private-key delivery via read-only volume mount)
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
- `anthropic` currently runs without server-managed tool calls.
## Searches
@@ -135,6 +249,16 @@ Behavior notes:
### `GET /v1/searches/:searchId`
- Response: `{ "search": SearchDetail }`
### `POST /v1/searches/:searchId/chat`
- Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.
### `POST /v1/searches/:searchId/run`
- Body:
```json
@@ -151,9 +275,36 @@ Behavior notes:
Search run notes:
- Backend executes Exa search and Exa answer.
- Search mode is independent from chat `web_search` tool configuration and remains Exa-only.
- Persists answer text/citations + ranked results.
- If both search and answer fail, endpoint returns an error.
### `POST /v1/searches/:searchId/run/stream`
- Body: same as `POST /v1/searches/:searchId/run`
- Response: `text/event-stream`
Events:
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
- `search_error`: `{ "error": string }`
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
- `answer_error`: `{ "error": string }`
- terminal `done`: `{ "search": SearchDetail }`
- terminal `error`: `{ "message": string }`
Behavior notes:
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.
### `POST /v1/searches/:searchId/run/stream/attach`
- Body: none
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
- Not found: `404 { "message": "active search stream not found" }`
Behavior notes:
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.
## Type Shapes
`ChatSummary`
@@ -163,9 +314,9 @@ Search run notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null"
}
```
@@ -178,10 +329,32 @@ Search run notes:
"role": "system|user|assistant|tool",
"content": "...",
"name": null,
"metadata": null
"metadata": {
"attachments": [
{
"kind": "image",
"id": "attachment-id",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 12345,
"dataUrl": "data:image/jpeg;base64,..."
},
{
"kind": "text",
"id": "attachment-id",
"filename": "notes.md",
"mimeType": "text/markdown",
"sizeBytes": 4567,
"text": "# Notes\\n...",
"truncated": false
}
]
}
}
```
`metadata` remains nullable. Tool-call log messages still use `metadata.kind = "tool_call"`; regular user messages with attachments use `metadata.attachments`.
`ChatDetail`
```json
{
@@ -189,9 +362,9 @@ Search run notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null",
"messages": [Message]
}

View File

@@ -4,11 +4,13 @@ This document defines the server-sent events (SSE) contract for chat completions
Endpoint:
- `POST /v1/chat-completions/stream`
- `POST /v1/chats/:chatId/stream/attach`
Transport:
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
- Events are emitted in SSE format (`event: ...`, `data: ...`)
- Request body is JSON
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
Authentication:
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
@@ -18,10 +20,34 @@ Authentication:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai",
"persist": true,
"provider": "openai|anthropic|xai|hermes-agent",
"model": "string",
"messages": [
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": [
{
"kind": "image",
"id": "attachment-id",
"filename": "photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 12345,
"dataUrl": "data:image/jpeg;base64,..."
},
{
"kind": "text",
"id": "attachment-id",
"filename": "notes.md",
"mimeType": "text/markdown",
"sizeBytes": 4567,
"text": "# Notes\\n...",
"truncated": false
}
]
}
],
"temperature": 0.2,
"maxTokens": 256
@@ -29,9 +55,29 @@ Authentication:
```
Notes:
- If `chatId` is omitted, backend creates a new chat.
- `persist` defaults to `true`.
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- Backend stores only new non-assistant input history rows to avoid duplicates.
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
Persisted chat streams with a `chatId` are backend-owned active runs:
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
- While running, `GET /v1/active-runs` includes the `chatId`.
- Starting a second persisted stream for the same active `chatId` returns `409`.
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
## Attach Endpoint
`POST /v1/chats/:chatId/stream/attach`
- Body: none.
- Response uses the same `text/event-stream` transport and event names as `POST /v1/chat-completions/stream`.
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
- Returns `404 { "message": "active chat stream not found" }` if no stream is currently active for that chat.
- Authentication is the same as all other API endpoints.
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
## Event Stream Contract
@@ -46,13 +92,15 @@ Event order:
```json
{
"type": "meta",
"chatId": "chat-id",
"callId": "llm-call-id",
"chatId": "chat-id-or-null",
"callId": "llm-call-id-or-null",
"provider": "openai",
"model": "gpt-4.1-mini"
}
```
For `persist: false` streams, `chatId` and `callId` are `null`.
### `delta`
```json
@@ -102,29 +150,44 @@ Event order:
## Provider Streaming Behavior
- `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`.
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
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.
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
- OpenAI Responses stream events are normalized by the backend into this SSE contract; clients do not consume OpenAI's raw Responses stream event names.
## Persistence + Consistency Model
Backend database remains source of truth.
During stream:
For persisted streams:
- Client may optimistically render accumulated `delta` text.
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
On successful completion:
On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
- Backend then emits `done`.
On failure:
On persisted failure:
- Backend records call error and emits `error`.
For `persist: false` streams:
- Client may render the same `meta`, `tool_call`, `delta`, and terminal events.
- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows.
- `done.text` is the canonical assistant text if the client later imports the result into a saved chat.
Client recommendation (for iOS/web):
1. Render deltas in real time for UX.
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.

View File

@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- `just build` will:
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
2. build scheme `Sybil` for `iPhone 16e` simulator.
- Preferred test command: `just test`
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
## Simulator Workflow
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
- To choose a screenshot path, run `just screenshot path=build/name.png`.
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
## App Structure
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- Shared iOS app code lives in Swift package:
@@ -35,8 +46,9 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
## Practical Notes
- Default API URL is `http://127.0.0.1:8787` (configurable in-app).
- Previously saved `/api` API roots are normalized to the server root by the iOS client.
- The iOS client preserves an explicit `/api` base path for proxied deployments.
- Provider fallback models:
- OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini`
- Hermes Agent: `hermes-agent`

17
ios/Apps/Sybil/Info.plist Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemType</key>
<string>net.buzzert.sybil2.quick-question</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Quick question</string>
<key>UIApplicationShortcutItemIconSymbolName</key>
<string>sparkles</string>
</dict>
</array>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -5,9 +5,90 @@ import UIKit
@main
struct SybilApp: App
{
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
SplitView()
}
.commands {
SybilCommands()
}
}
}
@MainActor
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
SybilHomeScreenQuickActionHandler.configureQuickActions()
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = SybilSceneDelegate.self
return configuration
}
func application(
_ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
}
}
@MainActor
final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
}
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
}
func sceneWillResignActive(_ scene: UIScene) {
SybilHomeScreenQuickActionHandler.configureQuickActions()
}
}
@MainActor
private enum SybilHomeScreenQuickActionHandler {
static func configureQuickActions() {
// The quick question action is static in Info.plist so it is available before first launch.
UIApplication.shared.shortcutItems = []
}
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
return false
}
Task { @MainActor in
SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
}
return true
}
}

View File

@@ -1,7 +1,9 @@
targets:
SybilApp:
type: application
platform: iOS
supportedDestinations:
- iOS
- macCatalyst
deploymentTarget: "18.0"
sources:
- Sources
@@ -12,16 +14,20 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
PRODUCT_NAME: Sybil
PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.0
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: 1.7
CURRENT_PROJECT_VERSION: 8
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait

View File

@@ -0,0 +1,33 @@
{
"originHash" : "a6321e2b291c1094ca66f749c90095f05aac7f8c6b4a6e54e0e77a1bb0e1a79f",
"pins" : [
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui.git",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3
}

View File

@@ -2,10 +2,42 @@ import SwiftUI
public struct SplitView: View {
@State private var viewModel = SybilViewModel()
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0
@State private var quickQuestionFocusRequest = 0
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
public init() {
private var keyboardActions: SybilKeyboardActions? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
return nil
}
return SybilKeyboardActions(
newChat: {
viewModel.startNewChat()
composerFocusRequest += 1
},
newSearch: {
viewModel.startNewSearch()
composerFocusRequest += 1
},
previousConversation: {
viewModel.selectPreviousSidebarItem()
},
nextConversation: {
viewModel.selectNextSidebarItem()
}
)
}
@MainActor public init() {
SybilFontRegistry.registerIfNeeded()
SybilTheme.applySystemAppearance()
}
public var body: some View {
@@ -23,19 +55,161 @@ public struct SplitView: View {
} else if horizontalSizeClass == .compact {
SybilPhoneShellView(viewModel: viewModel)
} else {
NavigationSplitView {
SybilSidebarView(viewModel: viewModel)
.navigationTitle("Sybil")
} detail: {
SybilWorkspaceView(viewModel: viewModel)
GeometryReader { proxy in
NavigationSplitView(columnVisibility: $columnVisibility) {
SybilSidebarView(viewModel: viewModel)
} detail: {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
onShowSidebar: showSidebar,
onRequestNewChat: {
viewModel.startNewChat()
composerFocusRequest += 1
}
)
}
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
}
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
}
}
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
.task {
await viewModel.bootstrap()
presentPendingQuickQuestionIfPossible()
}
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
}
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshAfterAppBecameActive(
refreshCollections: true,
refreshSelection: viewModel.hasRefreshableSelection
)
}
case .inactive:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}
}
}
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
private func showSidebar() {
withAnimation(.easeInOut(duration: 0.22)) {
columnVisibility = .all
}
}
private func queueQuickQuestionPresentation() {
hasPendingQuickQuestionPresentation = true
presentPendingQuickQuestionIfPossible()
}
private func presentPendingQuickQuestionIfPossible() {
guard hasPendingQuickQuestionPresentation,
!viewModel.isCheckingSession,
viewModel.isAuthenticated
else {
return
}
hasPendingQuickQuestionPresentation = false
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
}
private func handleQuickQuestionDismissed() {
viewModel.cancelQuickQuestion()
}
}
public struct SybilCommands: Commands {
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
keyboardActions?.newChat()
}
.keyboardShortcut("n", modifiers: .command)
.disabled(keyboardActions == nil)
Button("New Search") {
keyboardActions?.newSearch()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(keyboardActions == nil)
}
CommandMenu("Conversation") {
Button("Previous Conversation") {
keyboardActions?.previousConversation()
}
.keyboardShortcut("[", modifiers: .command)
.disabled(keyboardActions == nil)
Button("Next Conversation") {
keyboardActions?.nextConversation()
}
.keyboardShortcut("]", modifiers: .command)
.disabled(keyboardActions == nil)
}
}
}
private struct SybilKeyboardActions {
var newChat: () -> Void
var newSearch: () -> Void
var previousConversation: () -> Void
var nextConversation: () -> Void
}
private struct SybilKeyboardActionsKey: FocusedValueKey {
typealias Value = SybilKeyboardActions
}
private extension FocusedValues {
var sybilKeyboardActions: SybilKeyboardActions? {
get { self[SybilKeyboardActionsKey.self] }
set { self[SybilKeyboardActionsKey.self] = newValue }
}
}

View File

@@ -17,16 +17,18 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient {
actor SybilAPIClient: SybilAPIClienting {
private let configuration: APIConfiguration
private let session: URLSession
@MainActor
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@MainActor
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
@@ -47,11 +49,16 @@ actor SybilAPIClient {
return response.chats
}
func createChat(title: String? = nil) async throws -> ChatSummary {
func createChat(
title: String? = nil,
provider: Provider? = nil,
model: String? = nil,
messages: [CompletionRequestMessage]? = nil
) async throws -> ChatSummary {
let response = try await request(
"/v1/chats",
method: "POST",
body: AnyEncodable(ChatCreateBody(title: title)),
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
responseType: ChatCreateResponse.self
)
return response.chat
@@ -96,6 +103,16 @@ actor SybilAPIClient {
return response.search
}
func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/searches/\(searchID)/chat",
method: "POST",
body: AnyEncodable(SearchChatCreateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -104,6 +121,10 @@ actor SybilAPIClient {
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
}
func getActiveRuns() async throws -> ActiveRunsResponse {
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
}
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -121,43 +142,35 @@ actor SybilAPIClient {
)
try await stream(request: request) { eventName, dataText in
switch eventName {
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))
case "done":
do {
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
await onEvent(.done(payload))
} catch {
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
SybilLog.warning(
SybilLog.network,
"Recovered chat stream done payload from concatenated SSE data"
)
await onEvent(.done(recovered))
} else {
throw error
}
}
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
await onEvent(.ignored)
}
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Chat stream completed")
}
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/chats/\(chatID)/stream/attach",
method: "POST",
body: nil,
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Attaching chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Attached chat stream completed")
}
func runSearchStream(
searchID: String,
body: SearchRunRequest,
@@ -176,34 +189,35 @@ actor SybilAPIClient {
)
try await stream(request: request) { eventName, dataText in
switch eventName {
case "search_results":
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
await onEvent(.searchResults(payload))
case "search_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.searchError(payload))
case "answer":
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
await onEvent(.answer(payload))
case "answer_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.answerError(payload))
case "done":
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
await onEvent(.done(payload))
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
await onEvent(.ignored)
}
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Search stream completed")
}
func attachSearchStream(
searchID: String,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/searches/\(searchID)/run/stream/attach",
method: "POST",
body: nil,
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Attached search stream completed")
}
private func request<Response: Decodable>(
_ path: String,
method: String,
@@ -486,6 +500,75 @@ actor SybilAPIClient {
return try? Self.decodeJSON(type, from: data)
}
private static func handleCompletionStreamEvent(
eventName: String,
dataText: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
switch eventName {
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))
case "done":
do {
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
await onEvent(.done(payload))
} catch {
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
SybilLog.warning(
SybilLog.network,
"Recovered chat stream done payload from concatenated SSE data"
)
await onEvent(.done(recovered))
} else {
throw error
}
}
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
await onEvent(.ignored)
}
}
private static func handleSearchStreamEvent(
eventName: String,
dataText: String,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
switch eventName {
case "search_results":
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
await onEvent(.searchResults(payload))
case "search_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.searchError(payload))
case "answer":
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
await onEvent(.answer(payload))
case "answer_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.answerError(payload))
case "done":
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
await onEvent(.done(payload))
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
await onEvent(.ignored)
}
}
private static func flushSSEEvent(
eventName: inout String,
dataLines: inout [String]
@@ -539,6 +622,7 @@ actor SybilAPIClient {
struct CompletionStreamRequest: Codable, Sendable {
var chatId: String?
var persist: Bool? = nil
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
@@ -546,9 +630,16 @@ struct CompletionStreamRequest: Codable, Sendable {
private struct ChatCreateBody: Encodable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?
}
private struct SearchChatCreateBody: Encodable {
var title: String?
}

View File

@@ -0,0 +1,45 @@
import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
func listChats() async throws -> [ChatSummary]
func createChat(
title: String?,
provider: Provider?,
model: String?,
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary]
func createSearch(title: String?, query: String?) async throws -> SearchSummary
func getSearch(searchID: String) async throws -> SearchDetail
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
func deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func runSearchStream(
searchID: String,
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws
func attachSearchStream(
searchID: String,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws
}
extension SybilAPIClienting {
func createChat(title: String?) async throws -> ChatSummary {
try await createChat(title: title, provider: nil, model: nil, messages: nil)
}
}

View File

@@ -0,0 +1,222 @@
import SwiftUI
enum SybilAttachmentTone {
case composer
case user
case assistant
}
struct SybilAttachmentListView: View {
var attachments: [ChatAttachment]
var tone: SybilAttachmentTone
var onRemove: ((String) -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(attachments) { attachment in
Group {
if attachment.kind == .image {
imageCard(attachment)
} else {
textCard(attachment)
}
}
}
}
}
@ViewBuilder
private func imageCard(_ attachment: ChatAttachment) -> some View {
VStack(alignment: .leading, spacing: 0) {
if let image = SybilChatAttachmentSupport.image(for: attachment) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 180)
.clipped()
} else {
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(Color.black.opacity(0.18))
Image(systemName: "photo")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(height: 140)
}
HStack(alignment: .top, spacing: 10) {
Image(systemName: "photo")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(titleColor.opacity(0.92))
.frame(width: 26, height: 26)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
VStack(alignment: .leading, spacing: 2) {
Text(attachment.filename)
.font(.sybil(.footnote, weight: .medium))
.foregroundStyle(titleColor)
.lineLimit(1)
Text(attachment.mimeType)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
}
Spacer(minLength: 0)
if let onRemove {
removeButton(for: attachment.id, onRemove: onRemove)
}
}
.padding(12)
}
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(cardBorder, lineWidth: 1)
)
}
@ViewBuilder
private func textCard(_ attachment: ChatAttachment) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "doc.text")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(titleColor.opacity(0.92))
.frame(width: 26, height: 26)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
VStack(alignment: .leading, spacing: 2) {
Text(attachment.filename)
.font(.sybil(.footnote, weight: .medium))
.foregroundStyle(titleColor)
.lineLimit(1)
Text(
attachment.truncated == true
? "\(attachment.mimeType) • truncated"
: attachment.mimeType
)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
}
Spacer(minLength: 0)
if let onRemove {
removeButton(for: attachment.id, onRemove: onRemove)
}
}
Text(SybilChatAttachmentSupport.previewText(for: attachment))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(bodyColor)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.black.opacity(0.16))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white.opacity(0.05), lineWidth: 1)
)
)
}
.padding(12)
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(cardBorder, lineWidth: 1)
)
}
private func removeButton(for attachmentID: String, onRemove: @escaping (String) -> Void) -> some View {
Button {
onRemove(attachmentID)
} label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(SybilTheme.textMuted)
.frame(width: 24, height: 24)
.background(
Circle()
.fill(Color.white.opacity(0.06))
)
}
.buttonStyle(.plain)
.accessibilityLabel("Remove attachment")
}
private var cardBackground: some ShapeStyle {
switch tone {
case .composer:
return AnyShapeStyle(
LinearGradient(
colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surfaceStrong.opacity(0.78)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
case .user:
return AnyShapeStyle(Color.black.opacity(0.14))
case .assistant:
return AnyShapeStyle(
LinearGradient(
colors: [SybilTheme.surface.opacity(0.58), SybilTheme.surfaceStrong.opacity(0.42)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
private var cardBorder: Color {
switch tone {
case .composer:
return SybilTheme.border.opacity(0.82)
case .user:
return Color.white.opacity(0.12)
case .assistant:
return SybilTheme.border.opacity(0.58)
}
}
private var titleColor: Color {
switch tone {
case .composer, .assistant:
return SybilTheme.text
case .user:
return SybilTheme.text
}
}
private var bodyColor: Color {
switch tone {
case .composer, .assistant:
return SybilTheme.text.opacity(0.94)
case .user:
return SybilTheme.text.opacity(0.96)
}
}
}

View File

@@ -0,0 +1,36 @@
import UIKit
@MainActor
final class SybilBackgroundTaskAssertion {
private let name: String
private var identifier: UIBackgroundTaskIdentifier = .invalid
init?(name: String, onExpiration: @escaping @MainActor () -> Void = {}) {
self.name = name
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
Task { @MainActor in
guard let self else { return }
SybilLog.warning(SybilLog.app, "Background task expired: \(self.name)")
onExpiration()
self.end()
}
}
guard identifier != .invalid else {
SybilLog.warning(SybilLog.app, "Failed to acquire background task: \(name)")
return nil
}
SybilLog.debug(SybilLog.app, "Acquired background task: \(name)")
}
func end() {
guard identifier != .invalid else {
return
}
UIApplication.shared.endBackgroundTask(identifier)
identifier = .invalid
SybilLog.debug(SybilLog.app, "Ended background task: \(name)")
}
}

View File

@@ -0,0 +1,354 @@
import Foundation
import UniformTypeIdentifiers
import UIKit
enum ChatAttachmentError: LocalizedError {
case unsupportedType(String)
case imageTooLarge(String)
case textTooLarge(String)
case unreadableFile(String)
case unsupportedImageFormat(String)
case tooManyAttachments(Int)
var errorDescription: String? {
switch self {
case let .unsupportedType(filename):
return "Unsupported file type for '\(filename)'. Use PNG/JPEG images or text-based files."
case let .imageTooLarge(filename):
return "Image '\(filename)' exceeds the 6 MB upload limit."
case let .textTooLarge(filename):
return "Text file '\(filename)' exceeds the 8 MB upload limit."
case let .unreadableFile(filename):
return "Could not read '\(filename)'."
case let .unsupportedImageFormat(filename):
return "Image '\(filename)' could not be converted to PNG or JPEG."
case let .tooManyAttachments(limit):
return "You can attach up to \(limit) files per message."
}
}
}
enum SybilChatAttachmentSupport {
static let maxAttachmentsPerMessage = 8
static let maxImageBytes = 6 * 1024 * 1024
static let maxTextBytes = 8 * 1024 * 1024
static let maxTextCharacters = 200_000
private static let supportedTextExtensions: Set<String> = [
"txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "xml", "yaml", "yml", "html", "htm",
"css", "js", "jsx", "ts", "tsx", "py", "rb", "java", "c", "cc", "cpp", "h", "hpp", "go",
"rs", "sh", "sql", "log", "toml", "ini", "cfg", "conf", "swift", "kt", "m", "mm"
]
private static let supportedTextMimeTypes: Set<String> = [
"application/json",
"application/ld+json",
"application/sql",
"application/toml",
"application/x-httpd-php",
"application/x-javascript",
"application/x-sh",
"application/xml",
"application/yaml",
"application/x-yaml",
"image/svg+xml"
]
static func attachmentSummary(_ attachments: [ChatAttachment]) -> String {
guard !attachments.isEmpty else { return "" }
let names = attachments.map(\.filename).joined(separator: ", ")
return attachments.count == 1 ? names : "Attached: \(names)"
}
static func metadataValue(for attachments: [ChatAttachment]) -> JSONValue? {
guard !attachments.isEmpty else { return nil }
return .object([
"attachments": .array(attachments.map(\.jsonValue))
])
}
static func buildAttachments(from urls: [URL]) throws -> [ChatAttachment] {
try urls.map { try buildAttachment(fromFileURL: $0) }
}
static func buildImageAttachment(image: UIImage, filename: String = "pasted-image.jpg") throws -> ChatAttachment {
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
return try buildImageAttachment(data: pngData, filename: filename, contentType: .png)
}
guard let jpegData = image.jpegData(compressionQuality: 0.92) else {
throw ChatAttachmentError.unsupportedImageFormat(filename)
}
return try buildImageAttachment(data: jpegData, filename: filename, contentType: .jpeg)
}
static func buildTextAttachment(text: String, filename: String = "pasted-text.txt", mimeType: String = "text/plain") throws -> ChatAttachment {
let data = Data(text.utf8)
return try buildTextAttachment(data: data, filename: filename, mimeType: mimeType)
}
@MainActor
static func buildAttachments(from itemProviders: [NSItemProvider]) async throws -> [ChatAttachment] {
var attachments: [ChatAttachment] = []
for provider in itemProviders {
if let fileURL = try await loadFileURL(from: provider) {
attachments.append(try buildAttachment(fromFileURL: fileURL))
continue
}
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
if let attachment = try await loadImageAttachment(from: provider) {
attachments.append(attachment)
}
}
}
return attachments
}
static func previewText(for attachment: ChatAttachment) -> String {
let normalized = (attachment.text ?? "")
.replacingOccurrences(of: "\r", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty {
return "(empty file)"
}
if normalized.count <= 280 {
return normalized
}
let endIndex = normalized.index(normalized.startIndex, offsetBy: 280)
return normalized[..<endIndex].trimmingCharacters(in: .whitespacesAndNewlines) + "..."
}
static func image(for attachment: ChatAttachment) -> UIImage? {
guard attachment.kind == .image,
let dataURL = attachment.dataUrl,
let data = decodeDataURL(dataURL)
else {
return nil
}
return UIImage(data: data)
}
private static func buildAttachment(fromFileURL url: URL) throws -> ChatAttachment {
let accessed = url.startAccessingSecurityScopedResource()
defer {
if accessed {
url.stopAccessingSecurityScopedResource()
}
}
let filename = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey])
let contentType = resourceValues?.contentType ?? UTType(filenameExtension: url.pathExtension)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw ChatAttachmentError.unreadableFile(filename)
}
if contentType?.conforms(to: .image) == true {
return try buildImageAttachment(data: data, filename: filename, contentType: contentType)
}
if isTextLike(contentType: contentType, mimeType: contentType?.preferredMIMEType, filename: filename) {
return try buildTextAttachment(data: data, filename: filename, mimeType: contentType?.preferredMIMEType ?? "text/plain")
}
throw ChatAttachmentError.unsupportedType(filename)
}
static func buildImageAttachment(data: Data, filename: String, contentType: UTType?) throws -> ChatAttachment {
var mimeType = contentType?.preferredMIMEType
var payload = data
if mimeType != "image/png" && mimeType != "image/jpeg" {
guard let image = UIImage(data: data) else {
throw ChatAttachmentError.unsupportedImageFormat(filename)
}
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
payload = pngData
mimeType = "image/png"
} else if let jpegData = image.jpegData(compressionQuality: 0.92) {
payload = jpegData
mimeType = "image/jpeg"
} else {
throw ChatAttachmentError.unsupportedImageFormat(filename)
}
}
if payload.count > maxImageBytes {
throw ChatAttachmentError.imageTooLarge(filename)
}
let normalizedMimeType = (mimeType == "image/png") ? "image/png" : "image/jpeg"
let dataUrl = "data:\(normalizedMimeType);base64,\(payload.base64EncodedString())"
return .image(
filename: filename,
mimeType: normalizedMimeType,
sizeBytes: payload.count,
dataUrl: dataUrl
)
}
private static func buildTextAttachment(data: Data, filename: String, mimeType: String) throws -> ChatAttachment {
if data.count > maxTextBytes {
throw ChatAttachmentError.textTooLarge(filename)
}
let normalized = String(decoding: data, as: UTF8.self)
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\u{0000}", with: "")
let truncated = normalized.count > maxTextCharacters
let trimmedText: String
if truncated {
let endIndex = normalized.index(normalized.startIndex, offsetBy: maxTextCharacters)
trimmedText = String(normalized[..<endIndex])
} else {
trimmedText = normalized
}
return .text(
filename: filename,
mimeType: mimeType,
sizeBytes: data.count,
text: trimmedText,
truncated: truncated
)
}
private static func isTextLike(contentType: UTType?, mimeType: String?, filename: String) -> Bool {
if let contentType {
if contentType.conforms(to: .text) || contentType.conforms(to: .plainText) || contentType.conforms(to: .sourceCode) {
return true
}
if contentType.conforms(to: .json) || contentType.conforms(to: .xml) {
return true
}
}
if let mimeType {
if mimeType.hasPrefix("text/") {
return true
}
if supportedTextMimeTypes.contains(mimeType.lowercased()) {
return true
}
}
let ext = URL(fileURLWithPath: filename).pathExtension.lowercased()
return supportedTextExtensions.contains(ext)
}
private static func decodeDataURL(_ value: String) -> Data? {
guard let separator = value.firstIndex(of: ",") else {
return nil
}
let encoded = value[value.index(after: separator)...]
return Data(base64Encoded: String(encoded))
}
@MainActor
private static func loadFileURL(from provider: NSItemProvider) async throws -> URL? {
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
return nil
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in
if let error {
continuation.resume(throwing: error)
return
}
if let url = item as? URL {
continuation.resume(returning: url)
return
}
if let data = item as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) {
continuation.resume(returning: url)
return
}
if let string = item as? String,
let url = URL(string: string) {
continuation.resume(returning: url)
return
}
continuation.resume(returning: nil)
}
}
}
@MainActor
private static func loadImageAttachment(from provider: NSItemProvider) async throws -> ChatAttachment? {
let preferredImageType: UTType = if provider.hasItemConformingToTypeIdentifier(UTType.png.identifier) {
.png
} else if provider.hasItemConformingToTypeIdentifier(UTType.jpeg.identifier) {
.jpeg
} else {
.image
}
if let data = try await loadDataRepresentation(from: provider, type: preferredImageType) {
let filenameExtension = preferredImageType.preferredFilenameExtension ?? "jpg"
let filename = "pasted-image.\(filenameExtension)"
return try buildImageAttachment(data: data, filename: filename, contentType: preferredImageType)
}
if let image = try await loadUIImage(from: provider),
let jpegData = image.jpegData(compressionQuality: 0.92) {
return try buildImageAttachment(data: jpegData, filename: "pasted-image.jpg", contentType: .jpeg)
}
return nil
}
@MainActor
private static func loadDataRepresentation(from provider: NSItemProvider, type: UTType) async throws -> Data? {
guard provider.hasItemConformingToTypeIdentifier(type.identifier) else {
return nil
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: data)
}
}
}
@MainActor
private static func loadUIImage(from provider: NSItemProvider) async throws -> UIImage? {
guard provider.canLoadObject(ofClass: UIImage.self) else {
return nil
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadObject(ofClass: UIImage.self) { object, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: object as? UIImage)
}
}
}
}

View File

@@ -5,6 +5,11 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var tailSpacerHeight: CGFloat = 0
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
private var hasPendingAssistant: Bool {
messages.contains { message in
@@ -13,57 +18,66 @@ struct SybilChatTranscriptView: View {
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
}
ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending)
.id(message.id)
}
if isSending && !hasPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Assistant is typing…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.background {
if isStreamingPendingAssistant(message) {
GeometryReader { proxy in
Color.clear.preference(
key: SybilPendingAssistantHeightPreferenceKey.self,
value: proxy.size.height
)
}
}
}
.id("typing-indicator")
}
Color.clear
.frame(height: 2)
.id("chat-bottom-anchor")
.scaleEffect(x: 1, y: -1)
}
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
.scaleEffect(x: 1, y: -1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
.onChange(of: messages.map(\.id)) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
}
.onChange(of: isSending) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
.padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
.padding(.bottom, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
onViewportHeightChange?(proxy.size.height)
}
.onChange(of: proxy.size.height) { _, height in
onViewportHeightChange?(height)
}
}
}
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
onPendingAssistantHeightChange?(height)
}
.scaleEffect(x: 1, y: -1)
}
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
isSending && message.id.hasPrefix("temp-assistant-")
}
}
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
@@ -86,10 +100,8 @@ private struct MessageBubble: View {
}
var body: some View {
HStack(alignment: .top) {
if isUser {
Spacer(minLength: 44)
}
HStack(alignment: .top, spacing: 0) {
leadingSpacer
if let toolCallMetadata {
ToolCallActivityChip(
@@ -99,6 +111,13 @@ private struct MessageBubble: View {
)
} else {
VStack(alignment: .leading, spacing: 8) {
if !message.attachments.isEmpty {
SybilAttachmentListView(
attachments: message.attachments,
tone: isUser ? .user : .assistant
)
}
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
@@ -109,7 +128,7 @@ private struct MessageBubble: View {
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
} else {
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
@@ -118,6 +137,7 @@ private struct MessageBubble: View {
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
if isUser {
@@ -136,12 +156,24 @@ private struct MessageBubble: View {
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
}
if !isUser {
Spacer(minLength: 0)
}
trailingSpacer
}
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
}
@ViewBuilder
private var leadingSpacer: some View {
if isUser {
Spacer(minLength: 44)
}
}
@ViewBuilder
private var trailingSpacer: some View {
if !isUser {
Spacer(minLength: 0)
}
}
}
private struct ToolCallActivityChip: View {
@@ -238,6 +270,7 @@ private struct ToolCallActivityChip: View {
}
}
}
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(

View File

@@ -10,6 +10,7 @@ extension Theme {
.text {
FontFamily(.custom("Inter"))
FontSize(15)
ForegroundColor(SybilTheme.text)
}
.code {
FontFamilyVariant(.monospaced)
@@ -84,7 +85,7 @@ extension Theme {
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.relativeLineSpacing(.em(0.46))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in

View File

@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai
case anthropic
case xai
case hermesAgent = "hermes-agent"
public var displayName: String {
switch self {
case .openai: return "OpenAI"
case .anthropic: return "Anthropic"
case .xai: return "xAI"
case .hermesAgent: return "Hermes Agent"
}
}
}
@@ -21,6 +23,132 @@ public enum MessageRole: String, Codable, Hashable, Sendable {
case tool
}
public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
public enum Kind: String, Codable, Hashable, Sendable {
case image
case text
}
public var id: String
public var kind: Kind
public var filename: String
public var mimeType: String
public var sizeBytes: Int
public var dataUrl: String?
public var text: String?
public var truncated: Bool?
public init(
id: String,
kind: Kind,
filename: String,
mimeType: String,
sizeBytes: Int,
dataUrl: String? = nil,
text: String? = nil,
truncated: Bool? = nil
) {
self.id = id
self.kind = kind
self.filename = filename
self.mimeType = mimeType
self.sizeBytes = sizeBytes
self.dataUrl = dataUrl
self.text = text
self.truncated = truncated
}
public static func image(
id: String = UUID().uuidString,
filename: String,
mimeType: String,
sizeBytes: Int,
dataUrl: String
) -> ChatAttachment {
ChatAttachment(
id: id,
kind: .image,
filename: filename,
mimeType: mimeType,
sizeBytes: sizeBytes,
dataUrl: dataUrl
)
}
public static func text(
id: String = UUID().uuidString,
filename: String,
mimeType: String,
sizeBytes: Int,
text: String,
truncated: Bool
) -> ChatAttachment {
ChatAttachment(
id: id,
kind: .text,
filename: filename,
mimeType: mimeType,
sizeBytes: sizeBytes,
text: text,
truncated: truncated
)
}
var jsonValue: JSONValue {
var object: [String: JSONValue] = [
"kind": .string(kind.rawValue),
"id": .string(id),
"filename": .string(filename),
"mimeType": .string(mimeType),
"sizeBytes": .number(Double(sizeBytes))
]
if let dataUrl {
object["dataUrl"] = .string(dataUrl)
}
if let text {
object["text"] = .string(text)
}
if let truncated {
object["truncated"] = .bool(truncated)
}
return .object(object)
}
static func attachments(from metadata: JSONValue?) -> [ChatAttachment] {
guard let metadataObject = metadata?.objectValue,
let values = metadataObject["attachments"]?.arrayValue
else {
return []
}
return values.compactMap { value in
guard let object = value.objectValue,
let kindRaw = object["kind"]?.stringValue,
let kind = Kind(rawValue: kindRaw),
let id = object["id"]?.stringValue,
let filename = object["filename"]?.stringValue,
let mimeType = object["mimeType"]?.stringValue,
let sizeNumber = object["sizeBytes"]?.numberValue
else {
return nil
}
return ChatAttachment(
id: id,
kind: kind,
filename: filename,
mimeType: mimeType,
sizeBytes: Int(sizeNumber),
dataUrl: object["dataUrl"]?.stringValue,
text: object["text"]?.stringValue,
truncated: object["truncated"]?.boolValue
)
}
}
}
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
@@ -48,6 +176,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
public var name: String?
public var metadata: JSONValue? = nil
public var attachments: [ChatAttachment] {
ChatAttachment.attachments(from: metadata)
}
public var toolCallMetadata: ToolCallMetadata? {
guard role == .tool,
let object = metadata?.objectValue,
@@ -155,6 +287,20 @@ public enum JSONValue: Codable, Hashable, Sendable {
}
return nil
}
public var arrayValue: [JSONValue]? {
if case let .array(value) = self {
return value
}
return nil
}
public var boolValue: Bool? {
if case let .bool(value) = self {
return value
}
return nil
}
}
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
@@ -210,6 +356,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var results: [SearchResultItem]
}
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
public var chats: [String]
public var searches: [String]
public init(chats: [String] = [], searches: [String] = []) {
self.chats = chats
self.searches = searches
}
}
public struct SearchRunRequest: Codable, Sendable {
public var query: String?
public var title: String?
@@ -239,17 +395,19 @@ public struct CompletionRequestMessage: Codable, Sendable {
public var role: MessageRole
public var content: String
public var name: String?
public var attachments: [ChatAttachment]?
public init(role: MessageRole, content: String, name: String? = nil) {
public init(role: MessageRole, content: String, name: String? = nil, attachments: [ChatAttachment]? = nil) {
self.role = role
self.content = content
self.name = name
self.attachments = attachments
}
}
public struct CompletionStreamMeta: Codable, Sendable {
public var chatId: String
public var callId: String
public var chatId: String?
public var callId: String?
public var provider: Provider
public var model: String
}

View File

@@ -22,29 +22,473 @@ enum PhoneRoute: Hashable {
struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = []
@State private var route: PhoneRoute = .draftChat
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
@State private var isSidebarOverlayPresented = false
@State private var sidebarSwipeOffset: CGFloat = 0
@State private var sidebarSwipeIsActive = false
@State private var sidebarSwipeIsCompleting = false
@State private var sidebarSwipeHasLatched = false
@State private var sidebarHighlightSelection: SidebarSelection?
@State private var sidebarHighlightClearTask: Task<Void, Never>?
@State private var openingSelectionRequestID: UUID?
private var canRecognizeSidebarSwipe: Bool {
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
}
private var sidebarOverlayProgress: CGFloat {
if isSidebarOverlayPresented {
return 1
}
return SidebarOverlaySwipeMetrics.progress(
for: sidebarSwipeOffset,
width: phoneStackWidth
)
}
private var shouldRenderSidebarOverlay: Bool {
isSidebarOverlayPresented ||
sidebarSwipeIsActive ||
sidebarSwipeIsCompleting ||
sidebarOverlayProgress > 0.001
}
private var currentRouteSelection: SidebarSelection? {
switch route {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .draftChat, .draftSearch, .settings:
return nil
}
}
private var highlightedSidebarSelection: SidebarSelection? {
sidebarHighlightSelection ?? currentRouteSelection
}
var body: some View {
NavigationStack(path: $path) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
SybilWordmark(size: 19)
}
}
.navigationDestination(for: PhoneRoute.self) { route in
SybilPhoneDestinationView(viewModel: viewModel, route: route)
}
GeometryReader { proxy in
phoneStack(width: proxy.size.width)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
updatePhoneStackWidth(proxy.size.width)
}
.onChange(of: proxy.size.width) { _, width in
updatePhoneStackWidth(width)
}
}
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshAfterAppBecameActive(
refreshCollections: isSidebarOverlayPresented,
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
)
}
case .inactive:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}
}
}
private func phoneStack(width: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
phoneWorkspaceLayer
.zIndex(0)
phoneSidebarOverlayLayer(width: width)
.zIndex(1)
}
}
private var phoneWorkspaceLayer: some View {
SybilPhoneDestinationView(
viewModel: viewModel,
composerFocusRequest: $composerFocusRequest,
route: route,
onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay
)
.background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
.allowsHitTesting(!shouldRenderSidebarOverlay)
.background {
sidebarSwipeInstaller
}
}
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
VStack(spacing: 0) {
phoneOverlayTopBar
SybilPhoneSidebarRoot(
viewModel: viewModel,
highlightedSelection: highlightedSidebarSelection,
onSelect: openSidebarSelection,
onRoute: showRouteAndClearSidebarHighlight
)
}
.opacity(sidebarOverlayProgress)
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
.allowsHitTesting(isSidebarOverlayPresented)
.accessibilityHidden(!isSidebarOverlayPresented)
}
private var sidebarSwipeInstaller: some View {
WorkspaceSwipePanInstaller(
direction: .right,
isEnabled: canRecognizeSidebarSwipe,
onBegan: { width in
beginSidebarSwipe(containerWidth: width)
},
onChanged: { translationX, width in
updateSidebarSwipe(with: translationX, containerWidth: width)
},
onEnded: { translationX, width, velocityX, didFinish in
finishSidebarSwipe(
translationX: translationX,
containerWidth: width,
velocityX: velocityX,
didFinish: didFinish
)
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
guard !isSidebarOverlayPresented else {
return nil
}
return {
startNewChatFromDestination()
}
}
private var phoneOverlayTopBar: some View {
HStack(spacing: 12) {
SybilWordmark(size: 21)
Spacer()
Button {
hideSidebarOverlay()
} label: {
Image(systemName: "chevron.right.2")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(SybilTheme.text)
.frame(width: 54, height: 54)
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.fill(SybilTheme.surface.opacity(0.76))
)
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityLabel("Hide conversations")
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background {
SybilPhoneOverlayBlurBand(edge: .top)
.ignoresSafeArea(edges: .top)
}
}
private func updatePhoneStackWidth(_ width: CGFloat) {
phoneStackWidth = max(width, 1)
}
private func startNewChatFromDestination() {
viewModel.startNewChat()
composerFocusRequest += 1
showRoute(.draftChat)
}
private func showRoute(_ nextRoute: PhoneRoute) {
let update = {
route = nextRoute
}
if isSidebarOverlayPresented {
withAnimation(.easeOut(duration: 0.22)) {
update()
isSidebarOverlayPresented = false
}
} else {
update()
}
resetSidebarSwipe(animated: false)
}
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
showRoute(nextRoute)
clearSidebarHighlight()
}
private func showSidebarOverlay() {
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
resetSidebarSwipe(animated: false)
}
private func hideSidebarOverlay() {
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false
}
resetSidebarSwipe(animated: false)
}
private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return
}
let requestID = UUID()
openingSelectionRequestID = requestID
setSidebarHighlight(selection)
Task {
await viewModel.selectForNavigation(selection)
guard openingSelectionRequestID == requestID else {
return
}
showRoute(PhoneRoute.from(selection: selection))
openingSelectionRequestID = nil
clearSidebarHighlight(selection, after: .milliseconds(260))
}
}
private func setSidebarHighlight(_ selection: SidebarSelection) {
sidebarHighlightClearTask?.cancel()
sidebarHighlightSelection = selection
}
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
sidebarHighlightClearTask?.cancel()
sidebarHighlightClearTask = Task { @MainActor in
try? await Task.sleep(for: delay)
guard !Task.isCancelled,
sidebarHighlightSelection == selection,
openingSelectionRequestID == nil else {
return
}
sidebarHighlightSelection = nil
}
}
private func clearSidebarHighlight() {
sidebarHighlightClearTask?.cancel()
openingSelectionRequestID = nil
sidebarHighlightSelection = nil
}
private func beginSidebarSwipe(containerWidth: CGFloat) {
let update = {
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeIsActive = true
sidebarSwipeHasLatched = false
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction, update)
}
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: nextOffset,
width: containerWidth,
isCurrentlyLatched: sidebarSwipeHasLatched
)
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeOffset = nextOffset
sidebarSwipeHasLatched = nextLatched
}
}
private func finishSidebarSwipe(
translationX: CGFloat,
containerWidth: CGFloat,
velocityX: CGFloat,
didFinish: Bool
) {
guard sidebarSwipeIsActive else {
resetSidebarSwipe(animated: false)
return
}
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: finalOffset,
width: containerWidth,
isCurrentlyLatched: sidebarSwipeHasLatched
)
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
offset: finalOffset,
velocityX: velocityX,
width: containerWidth,
isLatched: finalLatched
) {
completeSidebarSwipe()
return
}
resetSidebarSwipe(animated: true, velocityX: velocityX)
}
private func completeSidebarSwipe() {
guard !sidebarSwipeIsCompleting else {
return
}
sidebarSwipeIsCompleting = true
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
resetSidebarSwipe(animated: false)
}
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = sidebarSwipeOffset
let reset = {
sidebarSwipeOffset = 0
sidebarSwipeIsActive = false
sidebarSwipeIsCompleting = false
sidebarSwipeHasLatched = false
}
if animated {
withAnimation(
SidebarOverlaySwipeMetrics.springAnimation(
currentOffset: currentOffset,
targetOffset: 0,
velocityX: velocityX
)
) {
reset()
}
} else {
reset()
}
}
}
private enum SidebarOverlaySwipeMetrics {
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
}
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.progress(for: offset, width: width)
}
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
}
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
}
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
}
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
}
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
(1 - min(max(progress, 0), 1)) * 18
}
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
min(max(progress, 0), 1) * 14
}
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
1 - (min(max(progress, 0), 1) * 0.22)
}
}
private struct SybilPhoneOverlayBlurBand: View {
var edge: VerticalEdge
var body: some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.opacity(0.34)
Rectangle()
.fill(
LinearGradient(
colors: gradientColors,
startPoint: edge == .top ? .top : .bottom,
endPoint: edge == .top ? .bottom : .top
)
)
}
}
private var gradientColors: [Color] {
[
Color.black.opacity(0.94),
SybilTheme.background.opacity(0.78),
Color.black.opacity(0)
]
}
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute]
var highlightedSelection: SidebarSelection?
var onSelect: (SidebarSelection) -> Void
var onRoute: (PhoneRoute) -> Void
var body: some View {
VStack(spacing: 0) {
@@ -60,50 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
SybilSidebarItemList(
viewModel: viewModel,
isSelected: { item in
highlightedSelection == item.selection
},
onSelect: { item in
onSelect(item.selection)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
SybilPhoneSidebarRow(item: item)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
}
)
}
.background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -118,19 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
path = [.settings]
viewModel.openSettings()
onRoute(.settings)
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
viewModel.startNewSearch()
path = [.draftSearch]
onRoute(.draftSearch)
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
viewModel.startNewChat()
path = [.draftChat]
onRoute(.draftChat)
}
}
.padding(.horizontal, 18)
@@ -168,79 +578,40 @@ private struct SybilPhoneSidebarRoot: View {
}
}
private struct SybilPhoneSidebarRow: View {
var item: SidebarItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
}
}
private struct SybilPhoneDestinationView: View {
@Bindable var viewModel: SybilViewModel
@Binding var composerFocusRequest: Int
let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
var body: some View {
SybilWorkspaceView(viewModel: viewModel)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationBarTitleDisplayMode(.inline)
.task(id: route) {
applyRoute()
}
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
applyRoute()
}
}
private func applyRoute() {
switch route {
case let .chat(chatID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
return
}
viewModel.select(.chat(chatID))
case let .search(searchID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
return
}
viewModel.select(.search(searchID))
case .draftChat:
viewModel.startNewChat()

View File

@@ -0,0 +1,19 @@
import Combine
import Foundation
public enum SybilHomeScreenQuickAction {
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
}
@MainActor
public final class SybilQuickActionRouter: ObservableObject {
public static let shared = SybilQuickActionRouter()
@Published public private(set) var quickQuestionPresentationRequest = 0
private init() {}
public func requestQuickQuestionPresentation() {
quickQuestionPresentationRequest += 1
}
}

View File

@@ -0,0 +1,297 @@
import MarkdownUI
import Observation
import SwiftUI
struct SybilQuickQuestionView: View {
@Bindable var viewModel: SybilViewModel
var focusRequest: Int
@Environment(\.dismiss) private var dismiss
@FocusState private var promptFocused: Bool
private var hasAnswerContent: Bool {
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
}
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
header
answerArea
composer
}
.padding(.horizontal, 16)
.padding(.top, 18)
.padding(.bottom, 12)
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(SybilTheme.backgroundGradient)
.preferredColorScheme(.dark)
.task(id: focusRequest) {
try? await Task.sleep(for: .milliseconds(260))
guard !Task.isCancelled else {
return
}
promptFocused = true
}
}
private var header: some View {
HStack {
Image(systemName: "sparkles")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(SybilTheme.primary)
Text("Quick question")
.font(.title3.weight(.semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var answerArea: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
}
if let error = viewModel.quickQuestionError {
Text(error)
.font(.caption)
.foregroundStyle(SybilTheme.danger)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(14)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.36))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
)
}
private var composer: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .bottom, spacing: 10) {
TextField(
"Ask anything...",
text: Binding(
get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) }
),
axis: .vertical
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submitQuestion)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button(action: submitQuestion) {
Image(systemName: "arrow.up")
.font(.body.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendQuickQuestion
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
)
)
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendQuickQuestion)
.accessibilityLabel("Ask quick question")
}
controlsRow
}
}
private var convertButton: some View {
Button {
Task {
let didConvert = await viewModel.convertQuickQuestionToChat()
if didConvert {
dismiss()
}
}
} label: {
Label("Chat", systemImage: "bubble.left")
.font(.caption.weight(.medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.buttonStyle(.plain)
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 40)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surfaceStrong.opacity(0.78))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
)
)
.disabled(!viewModel.canConvertQuickQuestion)
}
private var controlsRow: some View {
HStack(alignment: .center, spacing: 10) {
providerMenu
modelMenu
convertButton
}
}
private var providerMenu: some View {
Menu {
ForEach(viewModel.providerOptions, id: \.self) { provider in
Button {
viewModel.setQuickQuestionProvider(provider)
} label: {
if viewModel.quickQuestionProvider == provider {
Label(provider.displayName, systemImage: "checkmark")
} else {
Text(provider.displayName)
}
}
}
} label: {
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
}
.frame(maxWidth: .infinity)
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
.accessibilityLabel("Quick question provider")
}
private var modelMenu: some View {
Menu {
if viewModel.quickQuestionProviderModelOptions.isEmpty {
Text("No models")
} else {
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
Button {
viewModel.setQuickQuestionModel(model)
} label: {
if viewModel.quickQuestionModel == model {
Label(model, systemImage: "checkmark")
} else {
Text(model)
}
}
}
}
} label: {
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
}
.frame(maxWidth: .infinity)
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
.accessibilityLabel("Quick question model")
}
private func submitQuestion() {
_ = viewModel.sendQuickQuestion()
}
}
private struct QuickQuestionPickerPill: View {
var title: String
var body: some View {
HStack(spacing: 8) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.8)
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 40)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surfaceStrong.opacity(0.78))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
)
)
}
}
private struct QuickQuestionMessageView: View {
var message: Message
var isSending: Bool
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-quick-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
if let metadata = message.toolCallMetadata {
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking...")
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.font(.body)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.96))
.textSelection(.enabled)
}
}
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
return summary
}
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent
}
return "Ran \(metadata.toolName ?? "tool")."
}
}

View File

@@ -5,23 +5,62 @@ struct SybilSearchResultsView: View {
var search: SearchDetail?
var isLoading: Bool
var isRunning: Bool
var isStartingChat: Bool = false
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var onStartChat: (() -> Void)? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let query = search?.query, !query.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Results for")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
Text(query)
.font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Results for")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
Text(query)
.font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true)
Text(resultCountLabel)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
Text(resultCountLabel)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
}
if let onStartChat {
Button {
onStartChat()
} label: {
HStack(spacing: 8) {
if isStartingChat {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.text)
} else {
Image(systemName: "bubble.left.and.text.bubble.right")
.font(.system(size: 14, weight: .semibold))
}
Text(isStartingChat ? "Starting chat..." : "Chat with results")
.font(.sybil(.caption, weight: .semibold))
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.primary.opacity(0.14))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.disabled(!canStartChat)
.opacity(canStartChat ? 1 : 0.55)
}
}
}
@@ -61,7 +100,8 @@ struct SybilSearchResultsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 20)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -76,6 +116,13 @@ struct SybilSearchResultsView: View {
return "\(count) result\(count == 1 ? "" : "s")"
}
private var canStartChat: Bool {
guard let search, !isLoading, !isRunning, !isStartingChat else {
return false
}
return search.answerText?.isEmpty == false || !search.results.isEmpty
}
@ViewBuilder
private var answerCard: some View {
VStack(alignment: .leading, spacing: 10) {

View File

@@ -11,6 +11,12 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
}
private let defaults: UserDefaults
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
var adminToken: String
var preferredProvider: Provider
var preferredModelByProvider: [Provider: String]
var quickQuestionPreferredProvider: Provider
var quickQuestionPreferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider
self.preferredModelByProvider = [
let preferredModels: [Provider: String] = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
]
self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
]
}
@@ -53,6 +72,13 @@ final class SybilSettingsStore {
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
}
var trimmedTokenOrNil: String? {
@@ -68,15 +94,10 @@ final class SybilSettingsStore {
raw.removeLast()
}
guard var components = URLComponents(string: raw) else {
guard let components = URLComponents(string: raw) else {
return nil
}
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if path.lowercased() == "api" {
components.path = ""
}
return components.url
}
}

View File

@@ -4,13 +4,6 @@ import SwiftUI
struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel
private func iconName(for item: SidebarItem) -> String {
switch item.kind {
case .chat: return "message"
case .search: return "globe"
}
}
private func isSelected(_ item: SidebarItem) -> Bool {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
}
@@ -18,8 +11,6 @@ struct SybilSidebarView: View {
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
SybilWordmark(size: 31)
VStack(spacing: 10) {
sidebarActionButton(
title: "New chat",
@@ -59,121 +50,34 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
SybilSidebarItemList(
viewModel: viewModel,
isSelected: isSelected,
onSelect: { item in
viewModel.select(item.selection)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
viewModel.select(item.selection)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName(for: item))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
}
Divider()
.overlay(SybilTheme.border)
Button {
viewModel.openSettings()
} label: {
Label("Settings", systemImage: "gearshape")
.font(.sybil(.subheadline, weight: .medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
)
}
.buttonStyle(.plain)
.padding(10)
}
.background(SybilTheme.panelGradient)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
viewModel.openSettings()
} label: {
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
}
.accessibilityLabel("Settings")
}
}
}
private func sidebarActionButton(
@@ -202,3 +106,151 @@ struct SybilSidebarView: View {
.buttonStyle(.plain)
}
}
struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
var body: some View {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
}
}
struct SybilSidebarRow: View {
var item: SidebarItem
var isSelected: Bool
private var isHighlighted: Bool {
isSelected
}
private var iconName: String {
switch item.kind {
case .chat: return "message"
case .search: return "globe"
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
}
struct SybilSidebarActivityIndicator: View {
var body: some View {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
.tint(SybilTheme.accent)
.scaleEffect(0.82)
.frame(width: 16, height: 16)
.accessibilityLabel("Generating")
}
}

View File

@@ -1,6 +1,7 @@
import CoreText
import Foundation
import SwiftUI
import UIKit
enum SybilFontRegistry {
static func registerIfNeeded() {
@@ -8,7 +9,7 @@ enum SybilFontRegistry {
}
private static let registeredFonts: Void = {
for fontName in ["Inter", "Orbitron"] {
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
Bundle.main.url(forResource: fontName, withExtension: "ttf")
else {
@@ -78,6 +79,23 @@ enum SybilTheme {
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
@MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithOpaqueBackground()
navAppearance.backgroundColor = UIColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1)
navAppearance.shadowColor = UIColor(red: 0.24, green: 0.20, blue: 0.38, alpha: 0.9)
navAppearance.titleTextAttributes = [
.foregroundColor: UIColor(red: 0.96, green: 0.94, blue: 1.0, alpha: 1)
]
navAppearance.largeTitleTextAttributes = navAppearance.titleTextAttributes
UINavigationBar.appearance().prefersLargeTitles = false
UINavigationBar.appearance().standardAppearance = navAppearance
UINavigationBar.appearance().compactAppearance = navAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
UINavigationBar.appearance().compactScrollEdgeAppearance = navAppearance
}
static var backgroundGradient: LinearGradient {
LinearGradient(
colors: [
@@ -185,7 +203,7 @@ struct SybilWordmark: View {
var body: some View {
Text("SYBIL")
.font(.custom("Orbitron", size: size))
.font(.custom("Stalinist One", size: size))
.fontWeight(.black)
.tracking(0)
.foregroundStyle(SybilTheme.brandGradient)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,806 @@
import CoreGraphics
import Foundation
import Testing
@testable import Sybil
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
private struct MockClientCallSnapshot: Sendable {
var listChats = 0
var listSearches = 0
var createChat = 0
var getChat = 0
var getSearch = 0
var getActiveRuns = 0
var runCompletionStream = 0
var attachCompletionStream = 0
var attachSearchStream = 0
}
private struct ChatCreateCallSnapshot: Sendable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct UnexpectedClientCall: Error {}
private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary]
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]?
private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String?
private var completionStreamDelayNanoseconds: UInt64 = 0
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
private var completionAttachDelayNanoseconds: UInt64 = 0
private var searchStreamNetworkErrorMessage: String?
private var searchStreamDelayNanoseconds: UInt64 = 0
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
private var searchAttachDelayNanoseconds: UInt64 = 0
init(
chatsResponse: [ChatSummary] = [],
searchesResponse: [SearchSummary] = [],
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
) {
self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.activeRunsResponse = activeRunsResponse
}
func currentSnapshot() -> MockClientCallSnapshot {
snapshot
}
func currentCreateChatCall() -> ChatCreateCallSnapshot? {
lastCreateChatCall
}
func currentCompletionStreamBody() -> CompletionStreamRequest? {
lastCompletionStreamBody
}
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
completionStreamEvents = events
completionStreamDelayNanoseconds = delayNanoseconds
}
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds
}
func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds
}
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
getSearchDelayNanoseconds = delayNanoseconds
}
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
searchStreamNetworkErrorMessage = message
searchStreamDelayNanoseconds = delayNanoseconds
}
func setCompletionAttachEvents(
chatID: String,
events: [CompletionStreamEvent],
delayNanoseconds: UInt64 = 0
) {
completionAttachEvents[chatID] = events
completionAttachDelayNanoseconds = delayNanoseconds
}
func setSearchAttachEvents(
searchID: String,
events: [SearchStreamEvent],
delayNanoseconds: UInt64 = 0
) {
searchAttachEvents[searchID] = events
searchAttachDelayNanoseconds = delayNanoseconds
}
func verifySession() async throws -> AuthSession {
AuthSession(authenticated: true, mode: "open")
}
func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1
return chatsResponse
}
func createChat(
title: String?,
provider: Provider?,
model: String?,
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary {
snapshot.createChat += 1
lastCreateChatCall = ChatCreateCallSnapshot(
title: title,
provider: provider,
model: model,
messages: messages
)
if let createChatResponse {
return createChatResponse
}
throw UnexpectedClientCall()
}
func getChat(chatID: String) async throws -> ChatDetail {
snapshot.getChat += 1
if getChatDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
}
guard let detail = chatDetails[chatID] else {
throw UnexpectedClientCall()
}
return detail
}
func deleteChat(chatID: String) async throws {
throw UnexpectedClientCall()
}
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
throw UnexpectedClientCall()
}
func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1
return searchesResponse
}
func createSearch(title: String?, query: String?) async throws -> SearchSummary {
throw UnexpectedClientCall()
}
func getSearch(searchID: String) async throws -> SearchDetail {
snapshot.getSearch += 1
if getSearchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
}
guard let detail = searchDetails[searchID] else {
throw UnexpectedClientCall()
}
return detail
}
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary {
throw UnexpectedClientCall()
}
func deleteSearch(searchID: String) async throws {
throw UnexpectedClientCall()
}
func listModels() async throws -> ModelCatalogResponse {
ModelCatalogResponse(providers: [:])
}
func getActiveRuns() async throws -> ActiveRunsResponse {
snapshot.getActiveRuns += 1
return activeRunsResponse
}
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
snapshot.runCompletionStream += 1
lastCompletionStreamBody = body
if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
}
if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
}
if let completionStreamEvents {
for event in completionStreamEvents {
await onEvent(event)
}
return
}
throw UnexpectedClientCall()
}
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
snapshot.attachCompletionStream += 1
let events = completionAttachEvents[chatID] ?? []
for event in events {
await onEvent(event)
}
if completionAttachDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
}
}
func runSearchStream(
searchID: String,
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
if searchStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
}
if let searchStreamNetworkErrorMessage {
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
}
throw UnexpectedClientCall()
}
func attachSearchStream(
searchID: String,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
snapshot.attachSearchStream += 1
let events = searchAttachEvents[searchID] ?? []
for event in events {
await onEvent(event)
}
if searchAttachDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
}
}
}
@MainActor
private func testSettings(named name: String) -> SybilSettingsStore {
let defaults = UserDefaults(suiteName: name)!
defaults.removePersistentDomain(forName: name)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = "http://127.0.0.1:8787"
return settings
}
private func makeChatSummary(id: String, date: Date) -> ChatSummary {
ChatSummary(
id: id,
title: "Chat \(id)",
createdAt: date,
updatedAt: date,
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini"
)
}
private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail {
ChatDetail(
id: id,
title: "Chat \(id)",
createdAt: date,
updatedAt: date,
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini",
messages: [
Message(
id: "message-\(id)",
createdAt: date,
role: .assistant,
content: body,
name: nil
)
]
)
}
private func makeSearchSummary(id: String, date: Date) -> SearchSummary {
SearchSummary(
id: id,
title: "Search \(id)",
query: "query-\(id)",
createdAt: date,
updatedAt: date
)
}
private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail {
SearchDetail(
id: id,
title: "Search \(id)",
query: "query-\(id)",
createdAt: date,
updatedAt: date,
requestId: "request-\(id)",
latencyMs: 42,
error: nil,
answerText: answer,
answerRequestId: "answer-\(id)",
answerCitations: [],
answerError: nil,
results: []
)
}
@MainActor
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = "https://sybil.bajor.cloud/api/"
#expect(settings.normalizedAPIBaseURL?.absoluteString == "https://sybil.bajor.cloud/api")
}
@MainActor
@Test func normalizedAPIBaseURLTrimsWhitespaceAndTrailingSlashes() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = " http://127.0.0.1:8787/// "
#expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
}
@MainActor
@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let chat = makeChatSummary(id: "chat-1", date: date)
let search = makeSearchSummary(id: "search-1", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search],
chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-1")
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 1)
#expect(snapshot.listSearches == 1)
#expect(snapshot.getChat == 0)
#expect(snapshot.getSearch == 0)
#expect(viewModel.selectedItem == .chat("chat-1"))
}
@MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100)
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
let client = MockSybilClient(chatDetails: ["chat-2": detail])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-2")
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1)
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
}
@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200)
let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer")
let client = MockSybilClient(searchDetails: ["search-2": detail])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .search("search-2")
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0)
#expect(snapshot.getSearch == 1)
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
}
@MainActor
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_210)
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
await client.setGetChatDelay(50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-old")
viewModel.selectedChat = staleDetail
viewModel.select(.chat("chat-new"))
#expect(viewModel.displayedMessages.isEmpty)
#expect(viewModel.isLoadingSelection)
try await Task.sleep(nanoseconds: 90_000_000)
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_220)
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
await client.setGetChatDelay(20_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
#expect(viewModel.selectedItem == .chat("chat-fast"))
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_230)
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
await client.setGetChatDelay(100_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
#expect(viewModel.selectedItem == .chat("chat-slow"))
#expect(viewModel.displayedMessages.isEmpty)
#expect(viewModel.isLoadingSelection)
try await Task.sleep(nanoseconds: 150_000_000)
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_240)
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
await client.setCompletionStreamNetworkError(
"Network error -1005 while requesting POST: The network connection was lost.",
delayNanoseconds: 50_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-typing")
viewModel.selectedChat = detail
viewModel.composer = "continue"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
#expect(viewModel.isSendingVisibleChat)
viewModel.startNewChat()
#expect(viewModel.displayedMessages.isEmpty)
#expect(!viewModel.isSendingVisibleChat)
await sendTask.value
}
@MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient()
await client.setCompletionStreamEvents([
.delta(CompletionStreamDelta(text: "Reset it from ")),
.done(CompletionStreamDone(text: "Reset it from Settings."))
])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionPrompt = "How do I reset my password?"
let task = viewModel.sendQuickQuestion()
await task?.value
let snapshot = await client.currentSnapshot()
let body = await client.currentCompletionStreamBody()
#expect(snapshot.runCompletionStream == 1)
#expect(body?.persist == false)
#expect(body?.chatId == nil)
#expect(body?.provider == .openai)
#expect(body?.messages.first?.role == .user)
#expect(body?.messages.first?.content == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#expect(!viewModel.isQuickQuestionSending)
}
@MainActor
@Test func quickQuestionConvertCreatesSeededChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_250)
let chat = makeChatSummary(id: "quick-chat", date: date)
let detail = ChatDetail(
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini",
messages: [
Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
]
)
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: [chat.id: detail],
createChatResponse: chat
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
viewModel.quickQuestionSubmittedProvider = .openai
viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
viewModel.quickQuestionMessages = [
Message(
id: "temp-assistant-quick",
createdAt: date,
role: .assistant,
content: "Reset it from Settings.",
name: nil
)
]
let didConvert = await viewModel.convertQuickQuestionToChat()
let snapshot = await client.currentSnapshot()
let createCall = await client.currentCreateChatCall()
#expect(didConvert)
#expect(snapshot.createChat == 1)
#expect(createCall?.title == "How do I reset my password?")
#expect(createCall?.provider == .openai)
#expect(createCall?.model == "gpt-4.1-mini")
#expect(createCall?.messages?.map(\.role) == [.user, .assistant])
#expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
#expect(viewModel.selectedItem == .chat("quick-chat"))
#expect(viewModel.quickQuestionPrompt.isEmpty)
}
@MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = "http://127.0.0.1:8787"
let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
viewModel.modelCatalog = [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
]
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionModel("claude-3-haiku")
#expect(viewModel.quickQuestionProvider == .anthropic)
#expect(viewModel.quickQuestionModel == "claude-3-haiku")
#expect(settings.preferredProvider == .openai)
let reloadedSettings = SybilSettingsStore(defaults: defaults)
#expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
#expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
#expect(reloadedSettings.preferredProvider == .openai)
let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
#expect(reloadedViewModel.quickQuestionProvider == .anthropic)
#expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
#expect(reloadedViewModel.provider == .openai)
}
@MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_260)
let chat = makeChatSummary(id: "chat-active", date: date)
let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: ["chat-active": detail],
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
)
await client.setCompletionAttachEvents(
chatID: "chat-active",
events: [.delta(CompletionStreamDelta(text: "streaming"))],
delayNanoseconds: 100_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
await viewModel.reconnect()
try await Task.sleep(nanoseconds: 20_000_000)
let snapshot = await client.currentSnapshot()
#expect(snapshot.getActiveRuns >= 1)
#expect(snapshot.attachCompletionStream == 1)
#expect(viewModel.sidebarItems.first?.isRunning == true)
#expect(viewModel.isSendingVisibleChat)
#expect(viewModel.displayedMessages.last?.content == "streaming")
}
@MainActor
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_270)
let activeChat = makeChatSummary(id: "chat-active", date: date)
let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
let client = MockSybilClient(
chatsResponse: [idleChat, activeChat],
chatDetails: [
"chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
"chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
],
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.selectedItem = .chat("chat-idle")
viewModel.composer = "new message"
await viewModel.reconnect()
#expect(viewModel.selectedItem == .chat("chat-idle"))
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
#expect(!viewModel.isActiveSelectionSending)
#expect(viewModel.canSendComposer)
}
@MainActor
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_300)
let chat = makeChatSummary(id: "chat-3", date: date)
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: ["chat-3": refreshedDetail]
)
await client.setCompletionStreamNetworkError(
"Network error -1005 while requesting POST: The network connection was lost.",
delayNanoseconds: 50_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-3")
viewModel.selectedChat = initialDetail
viewModel.composer = "continue"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
viewModel.markAppInactiveForNetwork()
await sendTask.value
#expect(viewModel.errorMessage == nil)
#expect(viewModel.composer.isEmpty)
#expect(!viewModel.isSending)
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.getChat == 1)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
}
@MainActor
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_400)
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
let client = MockSybilClient(
searchDetails: ["search-3": refreshedDetail]
)
await client.setSearchStreamNetworkError(
"Network error -1005 while requesting POST: The network connection was lost.",
delayNanoseconds: 50_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .search("search-3")
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
viewModel.composer = "refresh me"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
viewModel.markAppInactiveForNetwork()
await sendTask.value
#expect(viewModel.errorMessage == nil)
#expect(viewModel.composer.isEmpty)
#expect(!viewModel.isSending)
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.getSearch == 1)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
}
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
let width: CGFloat = 390
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)
#expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
#expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
#expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
#expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
#expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
#expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
#expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
#expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
#expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
#expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
#expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
}
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
let targetHeight: CGFloat = 320
let baselineAssistantHeight: CGFloat = 28
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 120
) == 200
)
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 500
) == SybilTranscriptTailSpacer.minimumHeight
)
}

View File

@@ -1,10 +1,28 @@
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
simulator_name := "iPhone 16e"
derived_data := "build/DerivedData"
default:
@just build
build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
fi
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
run:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2
screenshot path="build/sybil-screenshot.png":
mkdir -p "$(dirname '{{path}}')"
xcrun simctl io booted screenshot '{{path}}'

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# Sybil Server
Backend API for:
- LLM multiplexer (OpenAI / Anthropic / xAI (Grok))
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
- Personal chat database (chats/messages + LLM call log)
## Stack
@@ -43,7 +43,23 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
- `XAI_API_KEY`
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
- `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
- `CHAT_CODEX_REMOTE_WORKDIR` (`/workspace/sybil-codex` by default; created and reused on the devbox)
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
## API
- `GET /health`

View File

@@ -11,6 +11,7 @@
"prebuild": "node scripts/ensure-prisma-client.mjs",
"dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
"start": "node dist/index.js",
"test": "node --test --import tsx tests/**/*.test.ts",
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",

View File

@@ -13,6 +13,7 @@ enum Provider {
openai
anthropic
xai
hermes_agent @map("hermes-agent")
}
enum MessageRole {

View File

@@ -0,0 +1,59 @@
export type SseStreamEvent = {
event: string;
data: unknown;
};
type SseStreamListener = (event: SseStreamEvent) => void;
export class ActiveSseStream {
private readonly events: SseStreamEvent[] = [];
private readonly listeners = new Set<SseStreamListener>();
private completed = false;
private resolveDone!: () => void;
readonly done: Promise<void>;
constructor() {
this.done = new Promise((resolve) => {
this.resolveDone = resolve;
});
}
get isCompleted() {
return this.completed;
}
emit(event: string, data: unknown) {
if (this.completed) return;
const entry = { event, data };
this.events.push(entry);
for (const listener of this.listeners) {
listener(entry);
}
}
complete(finalEvent?: SseStreamEvent) {
if (this.completed) return;
if (finalEvent) {
this.emit(finalEvent.event, finalEvent.data);
}
this.completed = true;
this.listeners.clear();
this.resolveDone();
}
subscribe(listener: SseStreamListener) {
for (const event of this.events) {
listener(event);
}
if (this.completed) {
return () => {};
}
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
}

View File

@@ -1,5 +1,59 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config as loadDotenv } from "dotenv";
import { z } from "zod";
import "dotenv/config";
loadDotenv({ quiet: true });
loadDotenv({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.env"), quiet: true });
const OptionalUrlSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().url().optional()
);
const DEFAULT_HERMES_AGENT_API_BASE_URL = "http://127.0.0.1:8642/v1";
const HermesAgentApiBaseUrlSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().url().default(DEFAULT_HERMES_AGENT_API_BASE_URL)
);
const ChatWebSearchEngineSchema = z.preprocess(
(value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
},
z.enum(["exa", "searxng"]).default("exa")
);
const BooleanFlagSchema = z.preprocess((value) => {
if (typeof value !== "string") return value;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return value;
}, z.boolean().default(false));
const OptionalTrimmedStringSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().min(1).optional()
);
function defaultedPositiveInt(defaultValue: number) {
return z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.coerce.number().int().positive().default(defaultValue)
);
}
function defaultedTrimmedString(defaultValue: string) {
return z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().min(1).default(defaultValue)
);
}
const EnvSchema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
@@ -12,7 +66,46 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(),
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(),
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
SEARXNG_BASE_URL: OptionalUrlSchema,
CHAT_MAX_TOOL_ROUNDS: defaultedPositiveInt(100),
// Optional chat-mode Codex tool. When enabled, the server SSHes into a remote
// devbox and runs `codex exec` in a persistent scratch directory there.
CHAT_CODEX_TOOL_ENABLED: BooleanFlagSchema,
CHAT_CODEX_REMOTE_HOST: OptionalTrimmedStringSchema,
CHAT_CODEX_REMOTE_USER: OptionalTrimmedStringSchema,
CHAT_CODEX_REMOTE_PORT: defaultedPositiveInt(22),
CHAT_CODEX_REMOTE_WORKDIR: defaultedTrimmedString("/workspace/sybil-codex"),
CHAT_CODEX_SSH_KEY_PATH: OptionalTrimmedStringSchema,
CHAT_CODEX_SSH_PRIVATE_KEY_B64: OptionalTrimmedStringSchema,
CHAT_CODEX_EXEC_TIMEOUT_MS: defaultedPositiveInt(600_000),
// Optional arbitrary shell tool that runs only on the configured devbox.
CHAT_SHELL_TOOL_ENABLED: BooleanFlagSchema,
CHAT_SHELL_EXEC_TIMEOUT_MS: defaultedPositiveInt(120_000),
}).superRefine((value, ctx) => {
if (value.CHAT_WEB_SEARCH_ENGINE === "searxng" && !value.SEARXNG_BASE_URL) {
ctx.addIssue({
code: "custom",
path: ["SEARXNG_BASE_URL"],
message: "SEARXNG_BASE_URL is required when CHAT_WEB_SEARCH_ENGINE=searxng",
});
}
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
ctx.addIssue({
code: "custom",
path: ["CHAT_CODEX_REMOTE_HOST"],
message: "CHAT_CODEX_REMOTE_HOST is required when CHAT_CODEX_TOOL_ENABLED=true or CHAT_SHELL_TOOL_ENABLED=true",
});
}
});
export type Env = z.infer<typeof EnvSchema>;

View File

@@ -9,6 +9,7 @@ import { warmModelCatalog } from "./llm/model-catalog.js";
import { registerRoutes } from "./routes.js";
const app = Fastify({
bodyLimit: 32 * 1024 * 1024,
disableRequestLogging: true,
logger: {
transport: {

View File

@@ -1,15 +1,33 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { convert as htmlToText } from "html-to-text";
import type OpenAI from "openai";
import { z } from "zod";
import { env } from "../env.js";
import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = 4;
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
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 MAX_CODEX_PROMPT_CHARACTERS = 60_000;
const DEFAULT_CODEX_MAX_OUTPUT_CHARACTERS = 24_000;
const MAX_CODEX_MAX_OUTPUT_CHARACTERS = 80_000;
const MAX_SHELL_COMMAND_CHARACTERS = 20_000;
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
const execFileAsync = promisify(execFile);
const WebSearchArgsSchema = z
.object({
@@ -21,6 +39,8 @@ const WebSearchArgsSchema = z
})
.strict();
type WebSearchArgs = z.infer<typeof WebSearchArgsSchema>;
const FetchUrlArgsSchema = z
.object({
url: z.string().trim().url(),
@@ -28,7 +48,79 @@ const FetchUrlArgsSchema = z
})
.strict();
const CHAT_TOOLS: any[] = [
const CodexExecArgsSchema = z
.object({
prompt: z.string().trim().min(1).max(MAX_CODEX_PROMPT_CHARACTERS),
maxCharacters: z.coerce.number().int().min(1_000).max(MAX_CODEX_MAX_OUTPUT_CHARACTERS).optional(),
})
.strict();
type CodexExecArgs = z.infer<typeof CodexExecArgsSchema>;
const ShellExecArgsSchema = z
.object({
command: z.string().trim().min(1).max(MAX_SHELL_COMMAND_CHARACTERS),
maxCharacters: z.coerce.number().int().min(1_000).max(MAX_SHELL_MAX_OUTPUT_CHARACTERS).optional(),
})
.strict();
type ShellExecArgs = z.infer<typeof ShellExecArgsSchema>;
const CODEX_EXEC_TOOL = {
type: "function",
function: {
name: "codex_exec",
description:
"Delegate a coding, terminal, or multi-step software task to a persistent remote Codex CLI workspace. Use for complex code changes, repository inspection, running programs/tests, debugging build failures, or other tasks that need a real shell. The task runs non-interactively; the remote Codex instance must make reasonable assumptions, complete the task, and return a final summary with relevant stdout/stderr.",
parameters: {
type: "object",
properties: {
prompt: {
type: "string",
description:
"A complete, self-contained instruction for the remote Codex instance. Include the goal, relevant context, constraints, and what result to report back.",
},
maxCharacters: {
type: "integer",
minimum: 1_000,
maximum: MAX_CODEX_MAX_OUTPUT_CHARACTERS,
description: "Maximum stdout/stderr characters returned to the model (default 24000).",
},
},
required: ["prompt"],
additionalProperties: false,
},
},
};
const SHELL_EXEC_TOOL = {
type: "function",
function: {
name: "shell_exec",
description:
"Run an arbitrary non-interactive shell command on the configured remote devbox, starting in the persistent scratch workspace. Use for quick Python scripts, calculations, file inspection, package/tool checks, tests, and command-line work that needs a real shell. This does not run inside the Sybil server container.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description:
"Shell command to run on the devbox. The command is executed with bash -lc when bash exists, otherwise sh -lc, starting in the persistent scratch workspace.",
},
maxCharacters: {
type: "integer",
minimum: 1_000,
maximum: MAX_SHELL_MAX_OUTPUT_CHARACTERS,
description: "Maximum stdout/stderr characters returned to the model (default 24000).",
},
},
required: ["command"],
additionalProperties: false,
},
},
};
const BASE_CHAT_TOOLS: any[] = [
{
type: "function",
function: {
@@ -90,10 +182,34 @@ const CHAT_TOOLS: any[] = [
},
];
const CHAT_TOOLS: any[] = [
...BASE_CHAT_TOOLS,
...(env.CHAT_CODEX_TOOL_ENABLED ? [CODEX_EXEC_TOOL] : []),
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
];
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
if (tool?.type !== "function") return tool;
return {
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: false,
};
});
export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " +
"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. " +
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
(env.CHAT_CODEX_TOOL_ENABLED
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
: "") +
(env.CHAT_SHELL_TOOL_ENABLED
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
: "") +
"Do not fabricate tool outputs; reason only from provided tool results.";
type ToolRunOutcome = {
@@ -187,6 +303,24 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
}
if (name === "codex_exec") {
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
if (status === "completed") {
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
}
return prompt ? `Codex task '${toSingleLine(prompt, 120)}' failed.${errSuffix}` : `Codex task failed.${errSuffix}`;
}
if (name === "shell_exec") {
const command = typeof args.command === "string" ? args.command.trim() : "";
if (status === "completed") {
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
}
return command
? `Devbox shell command '${toSingleLine(command, 120)}' failed.${errSuffix}`
: `Devbox shell command failed.${errSuffix}`;
}
if (status === "completed") {
return `Ran tool '${name}'.`;
}
@@ -246,29 +380,22 @@ function extractHtmlTitle(html: string) {
}
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 };
});
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
return messages.map((message) => buildOpenAIConversationMessage(message));
}
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const exa = exaClient();
const response = await exa.search(args.query, {
type: args.type ?? "auto",
@@ -292,6 +419,7 @@ async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const results = Array.isArray(response?.results) ? response.results : [];
return {
ok: true,
searchEngine: "exa",
query: args.query,
requestId: response?.requestId ?? null,
results: results.map((result: any, index: number) => ({
@@ -309,6 +437,40 @@ async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
};
}
async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const response = await searchSearxng(args.query, {
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
includeDomains: args.includeDomains,
excludeDomains: args.excludeDomains,
});
return {
ok: true,
searchEngine: "searxng",
query: args.query,
requestId: response.requestId,
results: response.results.map((result, index) => ({
rank: index + 1,
title: result.title,
url: result.url,
publishedDate: result.publishedDate,
author: null,
summary: result.summary,
text: result.text,
highlights: result.summary ? [clipText(result.summary, 280)] : [],
engines: result.engines,
})),
};
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
return runSearxngWebSearchTool(args);
}
return runExaWebSearchTool(args);
}
function assertSafeFetchUrl(urlRaw: string) {
const parsed = new URL(urlRaw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -379,9 +541,228 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
};
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function buildDevboxSshTarget() {
const host = env.CHAT_CODEX_REMOTE_HOST;
if (!host) {
throw new Error("CHAT_CODEX_REMOTE_HOST not set");
}
if (!env.CHAT_CODEX_REMOTE_USER || host.includes("@")) {
return host;
}
return `${env.CHAT_CODEX_REMOTE_USER}@${host}`;
}
function buildRemoteCodexCommand(prompt: string) {
const workdir = env.CHAT_CODEX_REMOTE_WORKDIR.trim();
const wrappedPrompt = [
"You are running in a non-interactive batch environment.",
"",
"Rules:",
"- Do not ask questions or wait for user input.",
"- Do not use interactive commands, editors, pagers, or prompts.",
"- If details are ambiguous, make a reasonable assumption and continue.",
"- Complete the task in one run, including any requested file edits, commands, and verification.",
"- End with a concise final report that includes changed files, commands run, and outcomes.",
"",
"Task:",
prompt,
].join("\n");
const codexCommand =
`codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check ${shellQuote(wrappedPrompt)} < /dev/null`;
return `mkdir -p ${shellQuote(workdir)} && cd ${shellQuote(workdir)} && ${codexCommand}`;
}
function buildRemoteShellCommand(command: string) {
const workdir = env.CHAT_CODEX_REMOTE_WORKDIR.trim();
const quotedCommand = shellQuote(command);
return (
`mkdir -p ${shellQuote(workdir)} && cd ${shellQuote(workdir)} && ` +
`if command -v bash >/dev/null 2>&1; then bash -lc ${quotedCommand}; else sh -lc ${quotedCommand}; fi`
);
}
async function withDevboxSshKeyPath<T>(fn: (keyPath?: string) => Promise<T>) {
if (env.CHAT_CODEX_SSH_KEY_PATH) {
return fn(env.CHAT_CODEX_SSH_KEY_PATH);
}
if (!env.CHAT_CODEX_SSH_PRIVATE_KEY_B64) {
return fn(undefined);
}
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "sybil-codex-ssh-"));
const keyPath = path.join(tmpDir, "id");
try {
await writeFile(keyPath, Buffer.from(env.CHAT_CODEX_SSH_PRIVATE_KEY_B64, "base64"), { mode: 0o600 });
return await fn(keyPath);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
function clipRemoteOutput(value: string, maxCharacters: number) {
if (value.length <= maxCharacters) {
return { text: value, truncated: false };
}
return {
text: `${value.slice(0, maxCharacters)}\n\n[truncated ${value.length - maxCharacters} characters]`,
truncated: true,
};
}
function bufferOrStringToString(value: unknown) {
if (typeof value === "string") return value;
if (Buffer.isBuffer(value)) return value.toString("utf8");
return "";
}
async function runCodexExecTool(input: unknown): Promise<ToolRunOutcome> {
if (!env.CHAT_CODEX_TOOL_ENABLED) {
return { ok: false, error: "codex_exec is disabled." };
}
const args: CodexExecArgs = CodexExecArgsSchema.parse(input);
const maxCharacters = args.maxCharacters ?? DEFAULT_CODEX_MAX_OUTPUT_CHARACTERS;
const sshTarget = buildDevboxSshTarget();
const remoteCommand = buildRemoteCodexCommand(args.prompt);
const run = async (keyPath?: string) => {
const sshArgs = [
"-n",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"UserKnownHostsFile=/tmp/sybil-codex-known-hosts",
"-p",
String(env.CHAT_CODEX_REMOTE_PORT),
];
if (keyPath) {
sshArgs.push("-i", keyPath);
}
sshArgs.push(sshTarget, remoteCommand);
try {
const result = await execFileAsync("ssh", sshArgs, {
timeout: env.CHAT_CODEX_EXEC_TIMEOUT_MS,
maxBuffer: REMOTE_EXEC_MAX_BUFFER_BYTES,
});
const stdout = clipRemoteOutput(bufferOrStringToString(result.stdout), maxCharacters);
const stderr = clipRemoteOutput(bufferOrStringToString(result.stderr), Math.min(maxCharacters, 12_000));
return {
ok: true,
host: env.CHAT_CODEX_REMOTE_HOST,
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
};
} catch (err: any) {
const stdout = clipRemoteOutput(bufferOrStringToString(err?.stdout), maxCharacters);
const stderr = clipRemoteOutput(bufferOrStringToString(err?.stderr), Math.min(maxCharacters, 12_000));
return {
ok: false,
error: err?.killed
? `Remote Codex command timed out after ${env.CHAT_CODEX_EXEC_TIMEOUT_MS}ms.`
: err?.message ?? String(err),
exitCode: typeof err?.code === "number" ? err.code : null,
signal: typeof err?.signal === "string" ? err.signal : null,
host: env.CHAT_CODEX_REMOTE_HOST,
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
};
}
};
return withDevboxSshKeyPath(run);
}
async function runShellExecTool(input: unknown): Promise<ToolRunOutcome> {
if (!env.CHAT_SHELL_TOOL_ENABLED) {
return { ok: false, error: "shell_exec is disabled." };
}
const args: ShellExecArgs = ShellExecArgsSchema.parse(input);
const maxCharacters = args.maxCharacters ?? DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS;
const sshTarget = buildDevboxSshTarget();
const remoteCommand = buildRemoteShellCommand(args.command);
const run = async (keyPath?: string) => {
const sshArgs = [
"-n",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"UserKnownHostsFile=/tmp/sybil-codex-known-hosts",
"-p",
String(env.CHAT_CODEX_REMOTE_PORT),
];
if (keyPath) {
sshArgs.push("-i", keyPath);
}
sshArgs.push(sshTarget, remoteCommand);
try {
const result = await execFileAsync("ssh", sshArgs, {
timeout: env.CHAT_SHELL_EXEC_TIMEOUT_MS,
maxBuffer: REMOTE_EXEC_MAX_BUFFER_BYTES,
});
const stdout = clipRemoteOutput(bufferOrStringToString(result.stdout), maxCharacters);
const stderr = clipRemoteOutput(bufferOrStringToString(result.stderr), Math.min(maxCharacters, 12_000));
return {
ok: true,
host: env.CHAT_CODEX_REMOTE_HOST,
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
command: args.command,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
};
} catch (err: any) {
const stdout = clipRemoteOutput(bufferOrStringToString(err?.stdout), maxCharacters);
const stderr = clipRemoteOutput(bufferOrStringToString(err?.stderr), Math.min(maxCharacters, 12_000));
return {
ok: false,
error: err?.killed
? `Remote shell command timed out after ${env.CHAT_SHELL_EXEC_TIMEOUT_MS}ms.`
: err?.message ?? String(err),
exitCode: typeof err?.code === "number" ? err.code : null,
signal: typeof err?.signal === "string" ? err.signal : null,
host: env.CHAT_CODEX_REMOTE_HOST,
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
command: args.command,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
};
}
};
return withDevboxSshKeyPath(run);
}
async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome> {
if (name === "web_search") return runWebSearchTool(args);
if (name === "fetch_url") return runFetchUrlTool(args);
if (name === "codex_exec") return runCodexExecTool(args);
if (name === "shell_exec") return runShellExecTool(args);
return { ok: false, error: `Unknown tool: ${name}` };
}
@@ -396,6 +777,49 @@ function parseToolArgs(raw: unknown) {
}
}
function buildEventArgs(name: string, args: Record<string, unknown>) {
if (name === "codex_exec" && typeof args.prompt === "string") {
return {
...args,
prompt: clipText(args.prompt, 1_000),
};
}
if (name === "shell_exec" && typeof args.command === "string") {
return {
...args,
command: clipText(args.command, 1_000),
};
}
return args;
}
function looksLikeDanglingToolIntent(text: string) {
const normalized = text
.toLowerCase()
.replace(/[`*_>#-]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return false;
if (normalized.length > 800) return false;
if (/\blet me know\b/.test(normalized) || /\bif you (want|would like)\b/.test(normalized)) return false;
return (
/\b(calling|running|executing|trying|checking|testing)\b.{0,80}\b(now|it|tool|command|shell_exec|codex_exec)\b/.test(normalized) ||
/\b(let me|i'?ll|i will)\b.{0,120}\b(run|execute|call|try|check|test)\b/.test(normalized) ||
/\b(stand by|hang on|one moment)\b/.test(normalized)
);
}
function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
conversation.push({ role: "assistant", content: text });
conversation.push({
role: "system",
content:
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.",
});
}
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
if (!usage) return false;
acc.inputTokens += usage.prompt_tokens ?? 0;
@@ -404,6 +828,72 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
return true;
}
function mergeResponsesUsage(acc: Required<ToolAwareUsage>, usage: any) {
if (!usage) return false;
acc.inputTokens += usage.input_tokens ?? 0;
acc.outputTokens += usage.output_tokens ?? 0;
acc.totalTokens += usage.total_tokens ?? 0;
return true;
}
function getResponseOutputItems(response: any) {
return Array.isArray(response?.output) ? response.output : [];
}
function extractResponsesText(response: any, fallback = "") {
if (typeof response?.output_text === "string") return response.output_text;
const parts: string[] = [];
for (const item of getResponseOutputItems(response)) {
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
for (const content of item.content) {
if (content?.type === "output_text" && typeof content.text === "string") {
parts.push(content.text);
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
parts.push(content.refusal);
}
}
}
return parts.join("") || fallback;
}
function extractChatCompletionContent(message: any) {
if (typeof message?.content === "string") return message.content;
if (!Array.isArray(message?.content)) return "";
return message.content
.map((part: any) => {
if (typeof part === "string") return part;
if (typeof part?.text === "string") return part.text;
if (typeof part?.content === "string") return part.content;
return "";
})
.join("");
}
function getUnstreamedText(finalText: string, streamedText: string) {
if (!finalText) return "";
if (!streamedText) return finalText;
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
}
function getResponseFailureMessage(response: any) {
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
}
function normalizeResponsesToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
return outputItems
.filter((item) => item?.type === "function_call")
.map((call: any, index: number) => ({
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
name: call.name ?? "unknown_tool",
arguments: call.arguments ?? "{}",
}));
}
type NormalizedToolCall = {
id: string;
name: string;
@@ -445,12 +935,13 @@ async function executeToolCallAndBuildEvent(
: undefined;
const completedAtMs = Date.now();
const eventArgs = buildEventArgs(call.name, parsedArgs);
const event: ToolExecutionEvent = {
toolCallId: call.id,
name: call.name,
status,
summary: buildToolSummary(call.name, parsedArgs, status, error),
args: parsedArgs,
summary: buildToolSummary(call.name, eventArgs, status, error),
args: eventArgs,
startedAt,
completedAt: new Date(completedAtMs).toISOString(),
durationMs: completedAtMs - startedAtMs,
@@ -466,12 +957,82 @@ async function executeToolCallAndBuildEvent(
}
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
let danglingToolIntentRetries = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const response = await params.client.responses.create({
model: params.model,
input,
temperature: params.temperature,
max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS,
tool_choice: "auto",
parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence.
store: true,
} as any);
rawResponses.push(response);
sawUsage = mergeResponsesUsage(usageAcc, response?.usage) || sawUsage;
const failureMessage = getResponseFailureMessage(response);
if (failureMessage) {
throw new Error(failureMessage);
}
const outputItems = getResponseOutputItems(response);
const normalizedToolCalls = normalizeResponsesToolCalls(outputItems, round);
if (!normalizedToolCalls.length) {
const text = extractResponsesText(response);
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(input, text);
continue;
}
return {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
toolEvents,
};
}
totalToolCalls += normalizedToolCalls.length;
input.push(...outputItems);
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
input.push({
type: "function_call_output",
call_id: call.id,
output: 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, api: "responses" },
toolEvents,
};
}
export async function runToolAwareChatCompletions(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;
let danglingToolIntentRetries = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await params.client.chat.completions.create({
@@ -497,8 +1058,14 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (!toolCalls.length) {
const text = typeof message.content === "string" ? message.content : "";
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(conversation, text);
continue;
}
return {
text: typeof message.content === "string" ? message.content : "",
text,
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
@@ -544,8 +1111,154 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
};
}
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages),
temperature: params.temperature,
max_tokens: params.maxTokens,
} as any);
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const sawUsage = mergeUsage(usageAcc, completion?.usage);
const message = completion?.choices?.[0]?.message;
return {
text: extractChatCompletionContent(message),
usage: sawUsage ? usageAcc : undefined,
raw: { response: completion, api: "chat.completions" },
toolEvents: [],
};
}
export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
let danglingToolIntentRetries = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const stream = await params.client.responses.create({
model: params.model,
input,
temperature: params.temperature,
max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS,
tool_choice: "auto",
parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence.
store: true,
stream: true,
} as any);
let roundText = "";
let streamedRoundText = "";
let roundHasToolCalls = false;
let canStreamRoundText = false;
let completedResponse: any | null = null;
const completedOutputItems: any[] = [];
for await (const event of stream as any as AsyncIterable<any>) {
rawResponses.push(event);
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
roundText += event.delta;
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
streamedRoundText += event.delta;
yield { type: "delta", text: event.delta };
}
} else if (event?.type === "response.output_item.added" && event.item) {
if (event.item.type === "function_call") {
roundHasToolCalls = true;
canStreamRoundText = false;
} else if (event.item.type === "message" && !roundHasToolCalls) {
canStreamRoundText = true;
}
} else if (event?.type === "response.output_item.done" && event.item) {
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
if (event.item.type === "function_call") {
roundHasToolCalls = true;
canStreamRoundText = false;
}
} else if (event?.type === "response.completed") {
completedResponse = event.response;
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
completedResponse = event.response;
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
} else if (event?.type === "error") {
throw new Error(event.message ?? "OpenAI Responses stream failed.");
}
}
const failureMessage = getResponseFailureMessage(completedResponse);
if (failureMessage) {
throw new Error(failureMessage);
}
const outputItems = getResponseOutputItems(completedResponse);
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
if (!normalizedToolCalls.length) {
const text = extractResponsesText(completedResponse, roundText);
if (
!streamedRoundText &&
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
looksLikeDanglingToolIntent(text)
) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(input, text);
continue;
}
const unstreamedText = getUnstreamedText(text, streamedRoundText);
if (unstreamedText) {
yield { type: "delta", text: unstreamedText };
}
yield {
type: "done",
result: {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
input.push(...responseOutputItems);
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
yield { type: "tool_call", event };
input.push({
type: "function_call_output",
call_id: call.id,
output: 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, api: "responses" },
toolEvents,
},
};
}
export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages);
const rawResponses: unknown[] = [];
@@ -553,6 +1266,7 @@ export async function* runToolAwareOpenAIChatStream(
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
let danglingToolIntentRetries = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const stream = await params.client.chat.completions.create({
@@ -567,6 +1281,8 @@ export async function* runToolAwareOpenAIChatStream(
} as any);
let roundText = "";
let streamedRoundText = "";
let roundHasToolCalls = false;
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
for await (const chunk of stream as any as AsyncIterable<any>) {
@@ -577,12 +1293,16 @@ export async function* runToolAwareOpenAIChatStream(
const deltaText = choice?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
roundText += deltaText;
if (roundToolCalls.size === 0) {
if (!roundHasToolCalls) {
streamedRoundText += deltaText;
yield { type: "delta", text: deltaText };
}
}
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
if (deltaToolCalls.length) {
roundHasToolCalls = true;
}
for (const toolCall of deltaToolCalls) {
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
@@ -608,6 +1328,19 @@ export async function* runToolAwareOpenAIChatStream(
}));
if (!normalizedToolCalls.length) {
if (
!streamedRoundText &&
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
looksLikeDanglingToolIntent(roundText)
) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(conversation, roundText);
continue;
}
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
if (unstreamedText) {
yield { type: "delta", text: unstreamedText };
}
yield {
type: "done",
result: {
@@ -621,7 +1354,7 @@ export async function* runToolAwareOpenAIChatStream(
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({
const assistantToolCallMessage: any = {
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
@@ -631,7 +1364,11 @@ export async function* runToolAwareOpenAIChatStream(
arguments: call.arguments,
},
})),
});
};
if (roundText) {
assistantToolCallMessage.content = roundText;
}
conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
@@ -655,3 +1392,41 @@ export async function* runToolAwareOpenAIChatStream(
},
};
}
export async function* runPlainChatCompletionsStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let text = "";
const stream = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages),
temperature: params.temperature,
max_tokens: params.maxTokens,
stream: true,
} as any);
for await (const chunk of stream as any as AsyncIterable<any>) {
rawResponses.push(chunk);
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
text += deltaText;
yield { type: "delta", text: deltaText };
}
}
yield {
type: "done",
result: {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
toolEvents: [],
},
};
}

View File

@@ -0,0 +1,268 @@
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
function escapeAttribute(value: string) {
return value.replace(/"/g, "&quot;");
}
function getImageAttachments(message: ChatMessage) {
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
}
function getTextAttachments(message: ChatMessage) {
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
}
function buildImageSummaryText(attachments: ChatImageAttachment[]) {
if (!attachments.length) return null;
const label = attachments.length === 1 ? "Attached image" : "Attached images";
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
}
function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
return [
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
`<attached_file filename="${escapeAttribute(attachment.filename)}" mime_type="${escapeAttribute(attachment.mimeType)}"${truncationNote}>`,
attachment.text,
"</attached_file>",
].join("\n");
}
function toOpenAIContent(message: ChatMessage) {
const imageAttachments = getImageAttachments(message);
const textAttachments = getTextAttachments(message);
if (!imageAttachments.length && !textAttachments.length) {
return message.content;
}
const parts: Array<Record<string, unknown>> = [];
for (const attachment of imageAttachments) {
parts.push({
type: "image_url",
image_url: {
url: attachment.dataUrl,
detail: "auto",
},
});
}
const imageSummary = buildImageSummaryText(imageAttachments);
if (imageSummary) {
parts.push({ type: "text", text: imageSummary });
}
for (const attachment of textAttachments) {
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
}
if (message.content.trim()) {
parts.push({ type: "text", text: message.content });
}
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
return parts[0].text;
}
return parts;
}
function toOpenAIResponsesContent(message: ChatMessage) {
const imageAttachments = getImageAttachments(message);
const textAttachments = getTextAttachments(message);
if (!imageAttachments.length && !textAttachments.length) {
return message.content;
}
const parts: Array<Record<string, unknown>> = [];
for (const attachment of imageAttachments) {
parts.push({
type: "input_image",
image_url: attachment.dataUrl,
detail: "auto",
});
}
const imageSummary = buildImageSummaryText(imageAttachments);
if (imageSummary) {
parts.push({ type: "input_text", text: imageSummary });
}
for (const attachment of textAttachments) {
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
}
if (message.content.trim()) {
parts.push({ type: "input_text", text: message.content });
}
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
return parts[0].text;
}
return parts;
}
function parseImageDataUrl(attachment: ChatImageAttachment) {
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
if (!match) {
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
}
const mediaType = match[1].toLowerCase();
if (mediaType !== attachment.mimeType) {
throw new Error(`Image attachment MIME type mismatch for '${attachment.filename}'.`);
}
return {
mediaType,
data: match[2].replace(/\s+/g, ""),
};
}
function toAnthropicContent(message: ChatMessage) {
const imageAttachments = getImageAttachments(message);
const textAttachments = getTextAttachments(message);
if (!imageAttachments.length && !textAttachments.length) {
return message.content;
}
const blocks: Array<Record<string, unknown>> = [];
for (const attachment of imageAttachments) {
const source = parseImageDataUrl(attachment);
blocks.push({
type: "image",
source: {
type: "base64",
media_type: source.mediaType,
data: source.data,
},
});
}
const imageSummary = buildImageSummaryText(imageAttachments);
if (imageSummary) {
blocks.push({ type: "text", text: imageSummary });
}
for (const attachment of textAttachments) {
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
}
if (message.content.trim()) {
blocks.push({ type: "text", text: message.content });
}
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
return blocks[0].text;
}
return blocks;
}
export function buildOpenAIConversationMessage(message: ChatMessage) {
if (message.role === "tool") {
const name = message.name?.trim() || "tool";
return {
role: "user",
content: `Tool output (${name}):\n${message.content}`,
};
}
const out: Record<string, unknown> = {
role: message.role,
content: toOpenAIContent(message),
};
if (message.name && (message.role === "assistant" || message.role === "user")) {
out.name = message.name;
}
return out;
}
export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
if (message.role === "tool") {
const name = message.name?.trim() || "tool";
return {
role: "user",
content: `Tool output (${name}):\n${message.content}`,
};
}
return {
role: message.role,
content: toOpenAIResponsesContent(message),
};
}
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
.filter(Boolean)
.join("\n\n");
}
export function buildAnthropicConversationMessage(message: ChatMessage) {
if (message.role === "system") {
throw new Error("System messages must be handled separately for Anthropic.");
}
if (message.role === "tool") {
const name = message.name?.trim() || "tool";
return {
role: "user",
content: `Tool output (${name}):\n${message.content}`,
};
}
return {
role: message.role === "assistant" ? "assistant" : "user",
content: toAnthropicContent(message),
};
}
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
if (!Array.isArray(input)) return [];
const attachments: ChatAttachment[] = [];
for (const entry of input) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const record = entry as Record<string, unknown>;
const kind = record.kind;
const id = typeof record.id === "string" ? record.id : "";
const filename = typeof record.filename === "string" ? record.filename : "";
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
if (kind === "image" && typeof record.dataUrl === "string") {
attachments.push({
kind,
id,
filename,
mimeType: mimeType === "image/png" ? "image/png" : "image/jpeg",
sizeBytes,
dataUrl: record.dataUrl,
});
continue;
}
if (kind === "text" && typeof record.text === "string") {
attachments.push({
kind,
id,
filename,
mimeType,
sizeBytes,
text: record.text,
truncated: record.truncated === true,
});
}
}
return attachments;
}

View File

@@ -1,5 +1,6 @@
import type { FastifyBaseLogger } from "fastify";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { env } from "../env.js";
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
import type { Provider } from "./types.js";
export type ProviderModelSnapshot = {
@@ -8,9 +9,9 @@ export type ProviderModelSnapshot = {
error: string | null;
};
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
const providers: Provider[] = ["openai", "anthropic", "xai"];
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000;
const modelCatalog: ModelCatalogSnapshot = {
@@ -19,10 +20,23 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null },
};
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
function isLikelyOpenAIResponsesModel(model: string) {
const id = model.toLowerCase();
if (id.includes("embedding") || id.includes("moderation")) return false;
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
let timeoutId: NodeJS.Timeout | null = null;
try {
@@ -42,7 +56,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
async function fetchProviderModels(provider: Provider) {
if (provider === "openai") {
const page = await openaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
return uniqSorted(page.data.map((model) => model.id).filter(isLikelyOpenAIResponsesModel));
}
if (provider === "anthropic") {
@@ -50,8 +64,15 @@ async function fetchProviderModels(provider: Provider) {
return uniqSorted(page.data.map((model) => model.id));
}
const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
if (provider === "xai") {
const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
const page = await hermesAgentClient().models.list();
const models = page.data.map((model) => model.id);
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
return uniqSorted(models);
}
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
@@ -66,7 +87,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
} catch (err: any) {
const message = err?.message ?? String(err);
modelCatalog[provider] = {
models: [],
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
loadedAt: new Date().toISOString(),
error: message,
};
@@ -75,25 +96,18 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger)));
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
}
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
return {
openai: {
models: [...modelCatalog.openai.models],
loadedAt: modelCatalog.openai.loadedAt,
error: modelCatalog.openai.error,
},
anthropic: {
models: [...modelCatalog.anthropic.models],
loadedAt: modelCatalog.anthropic.loadedAt,
error: modelCatalog.anthropic.error,
},
xai: {
models: [...modelCatalog.xai.models],
loadedAt: modelCatalog.xai.loadedAt,
error: modelCatalog.xai.error,
},
};
const snapshot: ModelCatalogSnapshot = {};
for (const provider of getCatalogProviders()) {
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
snapshot[provider] = {
models: [...entry.models],
loadedAt: entry.loadedAt,
error: entry.error,
};
}
return snapshot;
}

View File

@@ -1,12 +1,13 @@
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 { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) {
// Prisma enum values match these strings.
return p;
return toPrismaProvider(p);
}
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
@@ -47,8 +48,8 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
if (req.provider === "openai") {
const client = openaiClient();
const r = await runToolAwareOpenAIChat({
client,
model: req.model,
@@ -65,14 +66,46 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text;
usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "xai") {
const client = xaiClient();
const r = await runToolAwareChatCompletions({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
});
raw = r.raw;
outText = r.text;
usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "hermes-agent") {
const client = hermesAgentClient();
const r = await runPlainChatCompletions({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
});
raw = r.raw;
outText = r.text;
usage = r.usage;
} else if (req.provider === "anthropic") {
const client = anthropicClient();
// Anthropic splits system prompt. We'll convert first system message into system string.
const system = req.messages.find((m) => m.role === "system")?.content;
const msgs = req.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
const system = getAnthropicSystemPrompt(req.messages);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const r = await client.messages.create({
model: req.model,

View File

@@ -0,0 +1,31 @@
import type { Provider } from "./types.js";
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
export function toPrismaProvider(provider: Provider): PrismaProvider {
return provider === "hermes-agent" ? "hermes_agent" : provider;
}
export function fromPrismaProvider(provider: unknown): Provider | null {
if (provider === null || provider === undefined) return null;
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
return null;
}
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
const next: Record<string, any> = { ...value };
if ("initiatedProvider" in next) {
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
}
if ("lastUsedProvider" in next) {
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
}
if ("provider" in next) {
next.provider = fromPrismaProvider(next.provider);
}
if (Array.isArray(next.calls)) {
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
}
return next as T;
}

View File

@@ -13,6 +13,18 @@ export function xaiClient() {
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
}
export function isHermesAgentConfigured() {
return Boolean(env.HERMES_AGENT_API_KEY);
}
export function hermesAgentClient() {
if (!env.HERMES_AGENT_API_KEY) throw new Error("HERMES_AGENT_API_KEY not set");
return new OpenAI({
apiKey: env.HERMES_AGENT_API_KEY,
baseURL: env.HERMES_AGENT_API_BASE_URL,
});
}
export function anthropicClient() {
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });

View File

@@ -1,14 +1,28 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareOpenAIChatStream, type ToolExecutionEvent } from "./chat-tools.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import {
buildToolLogMessageData,
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream,
type ToolExecutionEvent,
} from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
export type StreamEvent =
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "delta"; text: string }
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
| { type: "done"; text: string; usage?: StreamUsage }
| { type: "error"; message: string };
function getChatIdOrCreate(chatId?: string) {
@@ -18,57 +32,90 @@ function getChatIdOrCreate(chatId?: string) {
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
const t0 = performance.now();
const chatId = await getChatIdOrCreate(req.chatId);
const shouldPersist = req.persist !== false;
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
const call = await prisma.llmCall.create({
data: {
chatId,
provider: req.provider as any,
model: req.model,
request: req as any,
},
select: { id: true },
});
const call =
shouldPersist && chatId
? await prisma.llmCall.create({
data: {
chatId,
provider: toPrismaProvider(req.provider) as any,
model: req.model,
request: req as any,
},
select: { id: true },
})
: null;
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedModel: req.model,
},
}),
]);
if (shouldPersist && chatId) {
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: toPrismaProvider(req.provider) as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: toPrismaProvider(req.provider) as any,
initiatedModel: req.model,
},
}),
]);
}
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
let text = "";
let usage: StreamEvent extends any ? any : never;
let usage: StreamUsage | undefined;
let raw: unknown = { streamed: true };
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
try {
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
for await (const ev of runToolAwareOpenAIChatStream({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
})) {
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const streamEvents =
req.provider === "openai"
? runToolAwareOpenAIChatStream({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId: chatId ?? undefined,
},
})
: req.provider === "hermes-agent"
? runPlainChatCompletionsStream({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId: chatId ?? undefined,
},
})
: runToolAwareChatCompletionsStream({
client,
model: req.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId: chatId ?? undefined,
},
});
for await (const ev of streamEvents) {
if (ev.type === "delta") {
text += ev.text;
yield { type: "delta", text: ev.text };
@@ -76,7 +123,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
}
if (ev.type === "tool_call") {
toolMessages.push(buildToolLogMessageData(chatId, ev.event));
if (shouldPersist && chatId) {
const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({
data: {
chatId: toolMessage.chatId,
role: toolMessage.role as any,
content: toolMessage.content,
name: toolMessage.name,
metadata: toolMessage.metadata as any,
},
});
}
yield { type: "tool_call", event: ev.event };
continue;
}
@@ -88,10 +146,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} else if (req.provider === "anthropic") {
const client = anthropicClient();
const system = req.messages.find((m) => m.role === "system")?.content;
const msgs = req.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
const system = getAnthropicSystemPrompt(req.messages);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const stream = await client.messages.create({
model: req.model,
@@ -129,43 +185,36 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
const latencyMs = Math.round(performance.now() - t0);
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,
})),
if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
latencyMs,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
});
}
await tx.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,
},
});
});
}
yield { type: "done", text, usage };
} catch (e: any) {
const latencyMs = Math.round(performance.now() - t0);
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
if (shouldPersist && call) {
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
}
yield { type: "error", message: e?.message ?? String(e) };
}
}

View File

@@ -1,13 +1,38 @@
export type Provider = "openai" | "anthropic" | "xai";
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
export type Provider = (typeof PROVIDERS)[number];
export type ChatImageAttachment = {
kind: "image";
id: string;
filename: string;
mimeType: "image/png" | "image/jpeg";
sizeBytes: number;
dataUrl: string;
};
export type ChatTextAttachment = {
kind: "text";
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
text: string;
truncated?: boolean;
};
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
export type ChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
export type MultiplexRequest = {
chatId?: string;
persist?: boolean;
provider: Provider;
model: string;
messages: ChatMessage[];

View File

@@ -1,26 +1,40 @@
import { performance } from "node:perf_hooks";
import { z } from "zod";
import type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ActiveSseStream, type SseStreamEvent } from "./active-streams.js";
import { prisma } from "./db.js";
import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
function sameMessage(
a: { role: string; content: string; name?: string | null },
b: { role: string; content: string; name?: string | null }
a: { role: string; content: string; name?: string | null; metadata?: unknown },
b: { role: string; content: string; name?: string | null; attachments?: ChatAttachment[] }
) {
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
const existingAttachments = JSON.stringify(buildComparableAttachments((a.metadata as Record<string, unknown> | null)?.attachments ?? null));
const incomingAttachments = JSON.stringify(b.attachments ?? []);
return (
a.role === b.role &&
a.content === b.content &&
(a.name ?? null) === (b.name ?? null) &&
existingAttachments === incomingAttachments
);
}
function isToolCallLogMetadata(value: unknown) {
@@ -60,10 +74,87 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
role: m.role as any,
content: m.content,
name: m.name,
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
})),
});
}
const MAX_CHAT_ATTACHMENTS = 8;
const MAX_IMAGE_ATTACHMENT_BYTES = 6 * 1024 * 1024;
const MAX_TEXT_ATTACHMENT_CHARS = 200_000;
const MAX_IMAGE_DATA_URL_CHARS = 8_500_000;
const ChatAttachmentSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("image"),
id: z.string().trim().min(1).max(128),
filename: z.string().trim().min(1).max(255),
mimeType: z.enum(["image/png", "image/jpeg"]),
sizeBytes: z.number().int().positive().max(MAX_IMAGE_ATTACHMENT_BYTES),
dataUrl: z
.string()
.max(MAX_IMAGE_DATA_URL_CHARS)
.regex(/^data:image\/(?:png|jpeg);base64,[a-z0-9+/=\s]+$/i, "Invalid image data URL"),
}),
z.object({
kind: z.literal("text"),
id: z.string().trim().min(1).max(128),
filename: z.string().trim().min(1).max(255),
mimeType: z.string().trim().min(1).max(127),
sizeBytes: z.number().int().positive().max(8 * 1024 * 1024),
text: z.string().max(MAX_TEXT_ATTACHMENT_CHARS),
truncated: z.boolean().optional(),
}),
]);
const CompletionMessageSchema = z
.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
attachments: z.array(ChatAttachmentSchema).max(MAX_CHAT_ATTACHMENTS).optional(),
})
.superRefine((value, ctx) => {
if (value.attachments?.length && value.role === "tool") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Tool messages cannot include attachments.",
path: ["attachments"],
});
}
});
const CompletionStreamBody = z
.object({
chatId: z.string().optional(),
persist: z.boolean().optional(),
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
})
.superRefine((value, ctx) => {
if (value.persist === false && value.chatId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "chatId must be omitted when persist is false",
path: ["chatId"],
});
}
});
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
if (!attachments?.length) return metadata as any;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return { attachments };
}
return {
...(metadata as Record<string, unknown>),
attachments,
};
}
const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
@@ -108,6 +199,13 @@ function mapSearchResultPreview(result: any, index: number) {
};
}
function truncateContextPart(value: string | null | undefined, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) return null;
if (trimmed.length <= maxLength) return trimmed;
return `${trimmed.slice(0, maxLength - 1).trimEnd()}...`;
}
function parseAnswerText(answerResponse: any) {
if (typeof answerResponse?.answer === "string") return answerResponse.answer;
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
@@ -129,16 +227,15 @@ async function generateChatTitle(content: string) {
const systemPrompt =
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
const userPrompt = `User request:\n${content}\n\nTitle:`;
const response = await openaiClient().chat.completions.create({
const response = await openaiClient().responses.create({
model: "gpt-4.1-mini",
temperature: 0,
max_completion_tokens: 20,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
max_output_tokens: 20,
instructions: systemPrompt,
input: userPrompt,
store: false,
});
return response.choices?.[0]?.message?.content ?? "";
return response.output_text ?? "";
}
function normalizeUrlForMatch(input: string | null | undefined) {
@@ -153,6 +250,57 @@ function normalizeUrlForMatch(input: string | null | undefined) {
}
}
function buildSearchChatContext(search: any) {
const query = truncateContextPart(search.query, 500) ?? truncateContextPart(search.title, 500) ?? "Untitled search";
const lines: string[] = [
"You are Sybil. The user started this chat from a saved web search. Use the search answer and result context below when answering follow-up questions. If the context is insufficient, say so and use available tools when appropriate.",
"",
`Search query: ${query}`,
];
const answer = truncateContextPart(search.answerText, 6000);
if (answer) {
lines.push("", "Search answer:", answer);
}
if (Array.isArray(search.answerCitations) && search.answerCitations.length) {
lines.push("", "Answer citations:");
for (const [index, citation] of search.answerCitations.slice(0, 8).entries()) {
const title = truncateContextPart(citation?.title, 160);
const url = truncateContextPart(citation?.url ?? citation?.id, 400);
if (title || url) {
lines.push(`${index + 1}. ${[title, url].filter(Boolean).join(" - ")}`);
}
}
}
if (Array.isArray(search.results) && search.results.length) {
lines.push("", "Search results:");
for (const result of search.results.slice(0, 10)) {
const title = truncateContextPart(result.title, 180) ?? result.url;
const url = truncateContextPart(result.url, 500);
const published = truncateContextPart(result.publishedDate, 80);
const author = truncateContextPart(result.author, 120);
const text = truncateContextPart(result.text, 1000);
const highlights = Array.isArray(result.highlights)
? result.highlights
.map((highlight: unknown) => truncateContextPart(typeof highlight === "string" ? highlight : null, 360))
.filter(Boolean)
: [];
lines.push(`${result.rank + 1}. ${title}`);
if (url) lines.push(` URL: ${url}`);
if (published || author) lines.push(` Source detail: ${[published, author].filter(Boolean).join(" - ")}`);
if (text) lines.push(` Text: ${text}`);
for (const highlight of highlights.slice(0, 2)) {
lines.push(` Highlight: ${highlight}`);
}
}
}
return lines.join("\n");
}
function buildSseHeaders(originHeader: string | undefined) {
const origin = originHeader && originHeader !== "null" ? originHeader : "*";
const headers: Record<string, string> = {
@@ -169,6 +317,246 @@ function buildSseHeaders(originHeader: string | undefined) {
return headers;
}
type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>();
const activeSearchStreams = new Map<string, ActiveSseStream>();
function getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : String(err);
}
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
if (reply.raw.destroyed || reply.raw.writableEnded) return;
reply.raw.write(`event: ${event.event}\n`);
reply.raw.write(`data: ${JSON.stringify(event.data)}\n\n`);
}
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders?.();
let unsubscribe = () => {};
let closed = false;
const closedPromise = new Promise<void>((resolve) => {
const onClose = () => {
closed = true;
unsubscribe();
reply.raw.off("close", onClose);
resolve();
};
reply.raw.on("close", onClose);
stream.done.finally(() => {
reply.raw.off("close", onClose);
});
});
unsubscribe = stream.subscribe((event) => writeSseEvent(reply, event));
await Promise.race([stream.done, closedPromise]);
unsubscribe();
if (!closed && !reply.raw.destroyed && !reply.raw.writableEnded) {
reply.raw.end();
}
return reply;
}
function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
if (ev.type === "tool_call") return { event: "tool_call", data: ev.event };
return { event: ev.type, data: ev };
}
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
const stream = new ActiveSseStream();
activeChatStreams.set(chatId, stream);
void (async () => {
let sawTerminalEvent = false;
try {
for await (const ev of runMultiplexStream(body)) {
const event = mapChatStreamEvent(ev);
if (ev.type === "done" || ev.type === "error") {
sawTerminalEvent = true;
stream.complete(event);
break;
}
stream.emit(event.event, event.data);
}
if (!sawTerminalEvent) {
stream.complete({ event: "error", data: { message: "chat stream ended unexpectedly" } });
}
} catch (err) {
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
} finally {
activeChatStreams.delete(chatId);
}
})();
return stream;
}
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
const startedAt = performance.now();
const query = body.query?.trim();
if (!query) {
stream.complete({ event: "error", data: { message: "query is required" } });
return;
}
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
try {
const exa = exaClient();
const searchPromise = exa.search(query, {
type: body.type ?? "auto",
numResults: body.numResults ?? 10,
includeDomains: body.includeDomains,
excludeDomains: body.excludeDomains,
moderation: true,
userLocation: "US",
contents: false,
} as any);
const answerPromise = exa.answer(query, {
text: true,
model: "exa",
userLocation: "US",
});
let searchResponse: any | null = null;
let answerResponse: any | null = null;
let enrichedResults: any[] | null = null;
let searchError: string | null = null;
let answerError: string | null = null;
const searchSettled = searchPromise.then(
async (value) => {
searchResponse = value;
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
stream.emit("search_results", {
requestId: value?.requestId ?? null,
results: previewResults,
});
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
if (!urls.length) return;
try {
const contentsResponse = await exa.getContents(urls, {
text: { maxCharacters: 1200 },
highlights: {
query,
maxCharacters: 320,
numSentences: 2,
highlightsPerUrl: 2,
},
} as any);
const byUrl = new Map<string, any>();
for (const contentItem of contentsResponse?.results ?? []) {
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
}
enrichedResults = (value?.results ?? []).map((result: any) => {
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
if (!contentItem) return result;
return {
...result,
text: contentItem.text ?? result.text ?? null,
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
};
});
stream.emit("search_results", {
requestId: value?.requestId ?? null,
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
});
} catch {
// keep preview results if content enrichment fails
}
},
(reason) => {
searchError = reason?.message ?? String(reason);
stream.emit("search_error", { error: searchError });
}
);
const answerSettled = answerPromise.then(
(value) => {
answerResponse = value;
stream.emit("answer", {
answerText: parseAnswerText(value),
answerRequestId: value?.requestId ?? null,
answerCitations: (value?.citations as any) ?? null,
});
},
(reason) => {
answerError = reason?.message ?? String(reason);
stream.emit("answer_error", { error: answerError });
}
);
await Promise.all([searchSettled, answerSettled]);
const latencyMs = Math.round(performance.now() - startedAt);
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
const answerText = parseAnswerText(answerResponse);
await prisma.$transaction(async (tx) => {
await tx.search.update({
where: { id: searchId },
data: {
query,
title: normalizedTitle,
requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any,
latencyMs,
error: searchError,
answerText,
answerRequestId: answerResponse?.requestId ?? null,
answerCitations: (answerResponse?.citations as any) ?? null,
answerRawResponse: answerResponse as any,
answerError,
},
});
await tx.searchResult.deleteMany({ where: { searchId } });
if (rows.length) {
await tx.searchResult.createMany({ data: rows as any });
}
});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) {
stream.complete({ event: "error", data: { message: "search not found" } });
} else {
stream.complete({ event: "done", data: { search } });
}
} catch (err) {
const message = getErrorMessage(err);
try {
await prisma.search.update({
where: { id: searchId },
data: {
query,
title: normalizedTitle,
latencyMs: Math.round(performance.now() - startedAt),
error: message,
},
});
} catch {
// keep the stream terminal event even if the backing search row disappeared
}
stream.complete({ event: "error", data: { message } });
} finally {
activeSearchStreams.delete(searchId);
}
}
export async function registerRoutes(app: FastifyInstance) {
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
@@ -182,6 +570,14 @@ export async function registerRoutes(app: FastifyInstance) {
return { providers: getModelCatalogSnapshot() };
});
app.get("/v1/active-runs", async (req) => {
requireAdmin(req);
return {
chats: Array.from(activeChatStreams.keys()),
searches: Array.from(activeSearchStreams.keys()),
};
});
app.get("/v1/chats", async (req) => {
requireAdmin(req);
const chats = await prisma.chat.findMany({
@@ -198,15 +594,55 @@ export async function registerRoutes(app: FastifyInstance) {
lastUsedModel: true,
},
});
return { chats };
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
});
app.post("/v1/chats", async (req) => {
requireAdmin(req);
const Body = z.object({ title: z.string().optional() });
const body = Body.parse(req.body ?? {});
const Body = z
.object({
title: z.string().optional(),
provider: ProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
messages: z.array(CompletionMessageSchema).optional(),
})
.superRefine((value, ctx) => {
if (value.provider && !value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "model is required when provider is supplied",
path: ["model"],
});
}
if (!value.provider && value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "provider is required when model is supplied",
path: ["provider"],
});
}
});
const parsed = Body.safeParse(req.body ?? {});
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const chat = await prisma.chat.create({
data: { title: body.title },
data: {
title: body.title,
initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
initiatedModel: body.model,
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model,
messages: body.messages?.length
? {
create: body.messages.map((message) => ({
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.attachments?.length ? ({ attachments: message.attachments } as any) : undefined,
})),
}
: undefined,
},
select: {
id: true,
title: true,
@@ -218,7 +654,7 @@ export async function registerRoutes(app: FastifyInstance) {
lastUsedModel: true,
},
});
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.patch("/v1/chats/:chatId", async (req) => {
@@ -249,7 +685,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/title/suggest", async (req) => {
@@ -274,7 +710,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: existing };
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content);
@@ -295,7 +731,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.delete("/v1/chats/:chatId", async (req) => {
@@ -370,6 +806,54 @@ export async function registerRoutes(app: FastifyInstance) {
return { search };
});
app.post("/v1/searches/:searchId/chat", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ title: z.string().optional() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
const fallbackTitle = search.query?.trim() || search.title?.trim() || "Search results";
const title = body.title?.trim() || `Search: ${fallbackTitle.slice(0, 72)}`;
const context = buildSearchChatContext(search);
const chat = await prisma.chat.create({
data: {
title,
messages: {
create: {
role: "system" as any,
content: context,
metadata: {
kind: "search_context",
searchId: search.id,
query: search.query,
resultCount: search.results.length,
},
},
},
},
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/searches/:searchId/run", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
@@ -483,162 +967,24 @@ export async function registerRoutes(app: FastifyInstance) {
const query = body.query?.trim() || existing.query?.trim();
if (!query) return app.httpErrors.badRequest("query is required");
const startedAt = performance.now();
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
const send = (event: string, data: any) => {
if (reply.raw.writableEnded) return;
reply.raw.write(`event: ${event}\n`);
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
const exa = exaClient();
const searchPromise = exa.search(query, {
type: body.type ?? "auto",
numResults: body.numResults ?? 10,
includeDomains: body.includeDomains,
excludeDomains: body.excludeDomains,
moderation: true,
userLocation: "US",
contents: false,
} as any);
const answerPromise = exa.answer(query, {
text: true,
model: "exa",
userLocation: "US",
});
let searchResponse: any | null = null;
let answerResponse: any | null = null;
let enrichedResults: any[] | null = null;
let searchError: string | null = null;
let answerError: string | null = null;
const searchSettled = searchPromise.then(
async (value) => {
searchResponse = value;
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
send("search_results", {
requestId: value?.requestId ?? null,
results: previewResults,
});
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
if (!urls.length) return;
try {
const contentsResponse = await exa.getContents(urls, {
text: { maxCharacters: 1200 },
highlights: {
query,
maxCharacters: 320,
numSentences: 2,
highlightsPerUrl: 2,
},
} as any);
const byUrl = new Map<string, any>();
for (const contentItem of contentsResponse?.results ?? []) {
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
}
enrichedResults = (value?.results ?? []).map((result: any) => {
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
if (!contentItem) return result;
return {
...result,
text: contentItem.text ?? result.text ?? null,
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
};
});
send("search_results", {
requestId: value?.requestId ?? null,
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
});
} catch {
// keep preview results if content enrichment fails
}
},
(reason) => {
searchError = reason?.message ?? String(reason);
send("search_error", { error: searchError });
}
);
const answerSettled = answerPromise.then(
(value) => {
answerResponse = value;
send("answer", {
answerText: parseAnswerText(value),
answerRequestId: value?.requestId ?? null,
answerCitations: (value?.citations as any) ?? null,
});
},
(reason) => {
answerError = reason?.message ?? String(reason);
send("answer_error", { error: answerError });
}
);
await Promise.all([searchSettled, answerSettled]);
const latencyMs = Math.round(performance.now() - startedAt);
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
const answerText = parseAnswerText(answerResponse);
await prisma.$transaction(async (tx) => {
await tx.search.update({
where: { id: searchId },
data: {
query,
title: normalizedTitle,
requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any,
latencyMs,
error: searchError,
answerText,
answerRequestId: answerResponse?.requestId ?? null,
answerCitations: (answerResponse?.citations as any) ?? null,
answerRawResponse: answerResponse as any,
answerError,
},
});
await tx.searchResult.deleteMany({ where: { searchId } });
if (rows.length) {
await tx.searchResult.createMany({ data: rows as any });
}
});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) {
send("error", { message: "search not found" });
} else {
send("done", { search });
}
} catch (err: any) {
await prisma.search.update({
where: { id: searchId },
data: {
query,
title: normalizedTitle,
latencyMs: Math.round(performance.now() - startedAt),
error: err?.message ?? String(err),
},
});
send("error", { message: err?.message ?? String(err) });
} finally {
reply.raw.end();
const existingStream = activeSearchStreams.get(searchId);
if (existingStream) {
return streamActiveRun(req, reply, existingStream);
}
return reply;
const stream = new ActiveSseStream();
activeSearchStreams.set(searchId, stream);
void executeSearchRunStream(searchId, { ...body, query }, stream);
return streamActiveRun(req, reply, stream);
});
app.post("/v1/searches/:searchId/run/stream/attach", async (req, reply) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const { searchId } = Params.parse(req.params);
const stream = activeSearchStreams.get(searchId);
if (!stream) return app.httpErrors.notFound("active search stream not found");
return streamActiveRun(req, reply, stream);
});
app.get("/v1/chats/:chatId", async (req) => {
@@ -651,7 +997,7 @@ export async function registerRoutes(app: FastifyInstance) {
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/:chatId/messages", async (req) => {
@@ -662,10 +1008,13 @@ export async function registerRoutes(app: FastifyInstance) {
content: z.string(),
name: z.string().optional(),
metadata: z.unknown().optional(),
attachments: z.array(ChatAttachmentSchema).max(MAX_CHAT_ATTACHMENTS).optional(),
});
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const msg = await prisma.message.create({
data: {
@@ -673,33 +1022,38 @@ export async function registerRoutes(app: FastifyInstance) {
role: body.role as any,
content: body.content,
name: body.name,
metadata: body.metadata as any,
metadata: mergeAttachmentsIntoMetadata(body.metadata, body.attachments) as any,
},
});
return { message: msg };
});
app.post("/v1/chats/:chatId/stream/attach", async (req, reply) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const { chatId } = Params.parse(req.params);
const stream = activeChatStreams.get(chatId);
if (!stream) return app.httpErrors.notFound("active chat stream not found");
return streamActiveRun(req, reply, stream);
});
// Main: create a completion via provider+model and store everything.
app.post("/v1/chat-completions", async (req) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
})
),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).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
if (body.chatId) {
@@ -724,22 +1078,9 @@ export async function registerRoutes(app: FastifyInstance) {
app.post("/v1/chat-completions/stream", async (req, reply) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
model: z.string().min(1),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
})
),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});
const body = Body.parse(req.body);
const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
// ensure chat exists if provided
if (body.chatId) {
@@ -748,26 +1089,28 @@ export async function registerRoutes(app: FastifyInstance) {
}
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) {
if (body.persist !== false && body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
}
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
const send = (event: string, data: any) => {
reply.raw.write(`event: ${event}\n`);
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
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);
if (body.persist !== false && body.chatId) {
if (activeChatStreams.has(body.chatId)) {
return app.httpErrors.conflict("chat completion already running");
}
const stream = startActiveChatStream(body.chatId, body);
return streamActiveRun(req, reply, stream);
}
reply.raw.end();
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(body)) {
writeSseEvent(reply, mapChatStreamEvent(ev));
}
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
reply.raw.end();
}
return reply;
});
}

View File

@@ -0,0 +1,160 @@
import { env } from "../env.js";
const SEARXNG_TIMEOUT_MS = 12_000;
const DEFAULT_SEARXNG_CATEGORIES = "general";
export type SearxngSearchOptions = {
numResults: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type SearxngSearchResult = {
title: string | null;
url: string | null;
publishedDate: string | null;
summary: string | null;
text: string | null;
engines: string[];
};
export type SearxngSearchResponse = {
query: string;
requestId: null;
results: SearxngSearchResult[];
};
function clipText(input: string, maxCharacters: number) {
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
}
function compactWhitespace(input: string) {
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
}
function requireSearxngBaseUrl() {
if (!env.SEARXNG_BASE_URL) {
throw new Error("SEARXNG_BASE_URL not set");
}
return env.SEARXNG_BASE_URL.endsWith("/") ? env.SEARXNG_BASE_URL : `${env.SEARXNG_BASE_URL}/`;
}
function normalizeDomain(input: string) {
const trimmed = input.trim().toLowerCase();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
return parsed.hostname.replace(/^www\./, "");
} catch {
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
}
}
function normalizeDomains(input: string[] | undefined) {
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
}
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
if (!urlRaw) return false;
try {
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
return hostname === domain || hostname.endsWith(`.${domain}`);
} catch {
return false;
}
}
function filterResultsByDomains(results: SearxngSearchResult[], options: SearxngSearchOptions) {
const includeDomains = normalizeDomains(options.includeDomains);
const excludeDomains = normalizeDomains(options.excludeDomains);
return results.filter((result) => {
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
return true;
});
}
function buildSearxngQuery(query: string, options: SearxngSearchOptions) {
const includeDomains = normalizeDomains(options.includeDomains);
const excludeDomains = normalizeDomains(options.excludeDomains);
const includeClause =
includeDomains.length === 0
? ""
: includeDomains.length === 1
? `site:${includeDomains[0]}`
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
}
function buildSearchUrl(query: string, options: SearxngSearchOptions) {
const url = new URL("search", requireSearxngBaseUrl());
url.searchParams.set("q", buildSearxngQuery(query, options));
url.searchParams.set("categories", DEFAULT_SEARXNG_CATEGORIES);
url.searchParams.set("language", "auto");
url.searchParams.set("safesearch", "1");
url.searchParams.set("format", "json");
return url;
}
async function fetchSearxng(url: URL, accept: string) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SEARXNG_TIMEOUT_MS);
try {
return await fetch(url, {
redirect: "follow",
signal: controller.signal,
headers: {
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: accept,
},
});
} finally {
clearTimeout(timeout);
}
}
function stringOrNull(value: unknown) {
if (typeof value !== "string") return null;
const normalized = compactWhitespace(value);
return normalized || null;
}
function stringArray(value: unknown) {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
}
function mapJsonResult(result: any): SearxngSearchResult {
const summary = stringOrNull(result?.content) ?? stringOrNull(result?.snippet);
const text = summary ? clipText(summary, 700) : null;
return {
title: stringOrNull(result?.title),
url: stringOrNull(result?.url),
publishedDate: stringOrNull(result?.publishedDate) ?? stringOrNull(result?.published_date),
summary: summary ? clipText(summary, 1_400) : null,
text,
engines: stringArray(result?.engines ?? (typeof result?.engine === "string" ? [result.engine] : [])),
};
}
export async function searchSearxng(query: string, options: SearxngSearchOptions): Promise<SearxngSearchResponse> {
const url = buildSearchUrl(query, options);
const response = await fetchSearxng(url, "application/json");
if (!response.ok) {
await response.arrayBuffer();
throw new Error(`SearXNG JSON search failed with status ${response.status}. Verify search.formats includes json.`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
await response.arrayBuffer();
throw new Error(`SearXNG JSON search returned ${contentType || "unknown content type"}.`);
}
const data: any = await response.json();
const results = Array.isArray(data?.results) ? data.results.map(mapJsonResult) : [];
return { query, requestId: null, results: filterResultsByDomains(results, options).slice(0, options.numResults) };
}

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ActiveSseStream, type SseStreamEvent } from "../src/active-streams.js";
test("ActiveSseStream replays buffered events to late subscribers", () => {
const stream = new ActiveSseStream();
stream.emit("delta", { text: "hel" });
stream.emit("delta", { text: "lo" });
const events: SseStreamEvent[] = [];
const unsubscribe = stream.subscribe((event) => events.push(event));
unsubscribe();
assert.deepEqual(events, [
{ event: "delta", data: { text: "hel" } },
{ event: "delta", data: { text: "lo" } },
]);
});
test("ActiveSseStream replays terminal events after completion", async () => {
const stream = new ActiveSseStream();
stream.emit("delta", { text: "done" });
stream.complete({ event: "done", data: { text: "done" } });
await stream.done;
const events: SseStreamEvent[] = [];
stream.subscribe((event) => events.push(event));
assert.equal(stream.isCompleted, true);
assert.deepEqual(events, [
{ event: "delta", data: { text: "done" } },
{ event: "done", data: { text: "done" } },
]);
});

View File

@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream,
type ToolAwareStreamingEvent,
} from "../src/llm/chat-tools.js";
async function* streamFrom(events: any[]) {
for (const event of events) {
await Promise.resolve();
yield event;
}
}
async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
const events: ToolAwareStreamingEvent[] = [];
for await (const event of iterable) {
events.push(event);
}
return events;
}
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
const outputMessage = {
id: "msg_1",
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
};
const client = {
responses: {
create: async () =>
streamFrom([
{ type: "response.output_item.added", item: { ...outputMessage, content: [] }, output_index: 0 },
{ type: "response.output_text.delta", delta: "Hel", output_index: 0, content_index: 0 },
{ type: "response.output_text.delta", delta: "lo", output_index: 0, content_index: 0 },
{ type: "response.output_item.done", item: outputMessage, output_index: 0 },
{
type: "response.completed",
response: {
status: "completed",
output_text: "Hello",
output: [outputMessage],
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
},
},
]),
},
};
const events = await collectEvents(
runToolAwareOpenAIChatStream({
client: client as any,
model: "gpt-test",
messages: [{ role: "user", content: "Say hello" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["delta", "delta", "done"]
);
assert.deepEqual(
events.filter((event) => event.type === "delta").map((event) => event.text),
["Hel", "lo"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
});
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
const client = {
chat: {
completions: {
create: async () =>
streamFrom([
{ choices: [{ delta: { role: "assistant" } }] },
{ choices: [{ delta: { content: "Hel" } }] },
{ choices: [{ delta: { content: "lo" } }] },
{
choices: [{ delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
},
]),
},
},
};
const events = await collectEvents(
runToolAwareChatCompletionsStream({
client: client as any,
model: "grok-test",
messages: [{ role: "user", content: "Say hello" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["delta", "delta", "done"]
);
assert.deepEqual(
events.filter((event) => event.type === "delta").map((event) => event.text),
["Hel", "lo"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
});
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
let requestBody: any = null;
const client = {
chat: {
completions: {
create: async (body: any) => {
requestBody = body;
return streamFrom([
{ choices: [{ delta: { content: "Hi" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]);
},
},
},
};
const events = await collectEvents(
runPlainChatCompletionsStream({
client: client as any,
model: "hermes-agent",
messages: [{ role: "user", content: "Say hi" }],
})
);
assert.equal(requestBody.model, "hermes-agent");
assert.equal(requestBody.stream, true);
assert.equal("tools" in requestBody, false);
assert.deepEqual(
events.map((event) => event.type),
["delta", "done"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
});

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
initiatedProvider: "hermes-agent",
lastUsedProvider: "xai",
});
});

View File

@@ -23,7 +23,7 @@ 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_PROVIDER`: `openai` | `anthropic` | `xai` | `hermes-agent` (default: `openai`)
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)

View File

@@ -1,6 +1,6 @@
import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
function normalizeBaseUrl(value: string) {
const trimmed = value.trim();

View File

@@ -39,11 +39,13 @@ type ToolLogMetadata = {
resultPreview?: string | null;
};
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
"hermes-agent": ["hermes-agent"],
};
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
@@ -74,6 +76,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
if (provider === "hermes-agent") return "Hermes Agent";
return "";
}
@@ -159,6 +162,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
return PROVIDER_FALLBACK_MODELS[provider];
}
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
}
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
if (fallback && options.includes(fallback)) return fallback;
if (preferred && options.includes(preferred)) return preferred;
@@ -202,6 +209,7 @@ async function main() {
openai: null,
anthropic: null,
xai: null,
"hermes-agent": null,
};
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
let errorMessage: string | null = null;
@@ -1257,8 +1265,10 @@ async function main() {
}
function cycleProvider() {
const currentIndex = PROVIDERS.indexOf(provider);
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
const visibleProviders = getVisibleProviders(modelCatalog);
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
const currentIndex = Math.max(0, cycleProviders.indexOf(provider));
const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai";
provider = nextProvider;
syncModelForProvider();
updateUI();

View File

@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai";
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = {
models: string[];
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ChatSummary = {

View File

@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
- Composer adapts to the active item:
- Chat sends `POST /v1/chat-completions/stream` (SSE).
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
- Keyboard shortcuts:
- `Cmd/Ctrl+J`: start a new chat.
- `Shift+Cmd/Ctrl+J`: start a new search.
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
Client API contract docs:
- `../docs/api/rest.md`

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import { FileText, Image as ImageIcon, X } from "lucide-preact";
import type { ChatAttachment } from "@/lib/api";
import { cn } from "@/lib/utils";
type Props = {
attachments: ChatAttachment[];
tone?: "composer" | "user" | "assistant";
onRemove?: (id: string) => void;
};
function getTextPreview(value: string) {
const normalized = value.replace(/\r/g, "").trim();
if (!normalized) return "(empty file)";
return normalized.length <= 280 ? normalized : `${normalized.slice(0, 280).trimEnd()}...`;
}
function getSurfaceClasses(tone: Props["tone"]) {
if (tone === "user") {
return "border-white/12 bg-black/16 text-fuchsia-50";
}
if (tone === "assistant") {
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
}
return "border-violet-300/18 bg-background/40 text-violet-50";
}
export function ChatAttachmentList({ attachments, tone = "composer", onRemove }: Props) {
if (!attachments.length) return null;
const surfaceClasses = getSurfaceClasses(tone);
return (
<div className="space-y-2">
{attachments.map((attachment) => {
const isImage = attachment.kind === "image";
return (
<div key={attachment.id} className={cn("overflow-hidden rounded-xl border", surfaceClasses)}>
{isImage ? (
<div className="grid gap-0 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
<div className="border-b border-white/10 bg-black/10 md:border-b-0 md:border-r">
<img src={attachment.dataUrl} alt={attachment.filename} className="block max-h-56 w-full object-cover" />
</div>
<div className="flex min-w-0 flex-col gap-2 p-3">
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
<ImageIcon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.filename}</p>
<p className="text-xs text-muted-foreground">{attachment.mimeType}</p>
</div>
{onRemove ? (
<button
type="button"
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
onClick={() => onRemove(attachment.id)}
aria-label={`Remove ${attachment.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
</div>
) : (
<div className="p-3">
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
<FileText className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.filename}</p>
<p className="text-xs text-muted-foreground">
{attachment.mimeType}
{attachment.truncated ? " · truncated" : ""}
</p>
</div>
{onRemove ? (
<button
type="button"
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
onClick={() => onRemove(attachment.id)}
aria-label={`Remove ${attachment.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<pre className="mt-2 overflow-x-auto rounded-lg border border-white/8 bg-black/16 p-3 text-xs leading-5 text-inherit whitespace-pre-wrap">
{getTextPreview(attachment.text)}
</pre>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { SearchDetail } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { cn } from "@/lib/utils";
import { MessageSquare } from "lucide-preact";
function formatHost(url: string) {
try {
@@ -29,6 +30,8 @@ type Props = {
className?: string;
enableKeyboardNavigation?: boolean;
openLinksInNewTab?: boolean;
isStartingChat?: boolean;
onStartChat?: () => void;
};
export function SearchResultsPanel({
@@ -38,6 +41,8 @@ export function SearchResultsPanel({
className,
enableKeyboardNavigation = false,
openLinksInNewTab = true,
isStartingChat = false,
onStartChat,
}: Props) {
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
@@ -133,17 +138,31 @@ export function SearchResultsPanel({
const isAnswerLoading = isRunning && !hasAnswerText;
const hasCitations = citationEntries.length > 0;
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
const canStartChat = !!search && !isLoading && !isRunning && !isStartingChat && (!!search.answerText || search.results.length > 0);
return (
<div className={className ?? "mx-auto w-full max-w-4xl"}>
{search?.query ? (
<div className="mb-5">
<p className="text-sm text-muted-foreground">Results for</p>
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{search.results.length} result{search.results.length === 1 ? "" : "s"}
{search.latencyMs ? `${search.latencyMs} ms` : ""}
</p>
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">Results for</p>
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{search.results.length} result{search.results.length === 1 ? "" : "s"}
{search.latencyMs ? `${search.latencyMs} ms` : ""}
</p>
</div>
{onStartChat ? (
<button
type="button"
className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg border border-violet-300/24 bg-violet-300/10 px-3 text-sm font-medium text-violet-50 transition hover:bg-violet-300/16 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onStartChat}
disabled={!canStartChat}
>
<MessageSquare className="h-4 w-4" />
{isStartingChat ? "Starting chat..." : "Chat with results"}
</button>
) : null}
</div>
) : null}

View File

@@ -0,0 +1,31 @@
import { useEffect } from "preact/hooks";
import { cn } from "@/lib/utils";
const CHARACTER_IDLE_SRC = "/character-idle.gif";
const CHARACTER_BUSY_SRC = "/character-busy.gif";
type SybilCharacterProps = {
className?: string;
isBusy?: boolean;
};
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
useEffect(() => {
const busyImage = new Image();
busyImage.src = CHARACTER_BUSY_SRC;
}, []);
return (
<img
aria-hidden="true"
alt=""
className={cn(
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
className
)}
data-state={isBusy ? "busy" : "idle"}
draggable={false}
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
/>
);
}

View File

@@ -4,6 +4,14 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "StalinistOne";
src: url("/StalinistOne-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--background: 235 45% 4%;
@@ -57,8 +65,8 @@ textarea {
}
.sybil-wordmark {
font-family: "Orbitron", "Inter", sans-serif;
font-weight: 900;
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
font-weight: 400;
letter-spacing: 0;
line-height: 1;
}
@@ -83,6 +91,77 @@ textarea {
word-break: break-word;
}
.md-table-scroll {
max-width: 100%;
margin: 0.35rem 0 1rem;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
.md-content table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.94em;
line-height: 1.48;
}
.md-table-scroll::-webkit-scrollbar {
height: 0.45rem;
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: hsl(263 78% 72% / 0.34);
}
.md-content th,
.md-content td {
padding: 0.48rem 0.7rem;
border-right: 1px solid hsl(var(--border) / 0.72);
border-bottom: 1px solid hsl(var(--border) / 0.7);
text-align: left;
vertical-align: top;
word-break: normal;
}
.md-content th:last-child,
.md-content td:last-child {
border-right: 0;
}
.md-content tr:last-child td {
border-bottom: 0;
}
.md-content th {
background: hsl(251 40% 15% / 0.92);
color: hsl(258 36% 98%);
font-weight: 700;
white-space: nowrap;
}
.md-content td {
color: hsl(258 34% 94% / 0.96);
}
.md-content tbody tr:nth-child(odd) td {
background: hsl(242 32% 10% / 0.58);
}
.md-content tbody tr:nth-child(even) td {
background: hsl(252 36% 13% / 0.46);
}
.md-content tbody tr:hover td {
background: hsl(263 46% 20% / 0.48);
}
.md-content p + p {
margin-top: 0.85rem;
}
@@ -113,7 +192,13 @@ textarea {
margin-top: 0.65rem;
margin-left: 0;
padding-left: 0;
list-style-position: inside;
list-style: none;
}
.md-content li > ul,
.md-content li > ol {
margin-top: 0.3rem;
padding-left: 1.35rem;
}
.md-content li + li {
@@ -121,17 +206,31 @@ textarea {
}
.md-content code {
background: hsl(288 22% 23%);
border-radius: 0.25rem;
background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem;
padding: 0.05rem 0.3rem;
font-size: 0.86em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.md-content pre {
overflow-x: auto;
border-radius: 0.5rem;
background: hsl(287 28% 13%);
padding: 0.6rem 0.75rem;
border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem;
background: hsl(249 40% 10% / 0.82);
padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
}
.md-content pre code {
display: block;
background: transparent;
border-radius: 0;
padding: 0;
font-size: 0.88em;
line-height: 1.55;
white-space: pre;
}
.md-content a {

View File

@@ -90,6 +90,27 @@ export type SearchDetail = {
results: SearchResultItem[];
};
export type ChatImageAttachment = {
kind: "image";
id: string;
filename: string;
mimeType: "image/png" | "image/jpeg";
sizeBytes: number;
dataUrl: string;
};
export type ChatTextAttachment = {
kind: "text";
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
text: string;
truncated?: boolean;
};
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
export type SearchRunRequest = {
query?: string;
title?: string;
@@ -103,9 +124,10 @@ export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
export type Provider = "openai" | "anthropic" | "xai";
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = {
models: string[];
@@ -114,7 +136,12 @@ export type ProviderModelInfo = {
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ActiveRunsResponse = {
chats: string[];
searches: string[];
};
type CompletionResponse = {
@@ -126,13 +153,20 @@ type CompletionResponse = {
};
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
};
type CreateChatRequest = {
title?: string;
provider?: Provider;
model?: string;
messages?: CompletionRequestMessage[];
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN;
@@ -188,10 +222,15 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function createChat(title?: string) {
export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs");
}
export async function createChat(input?: string | CreateChatRequest) {
const body = typeof input === "string" ? { title: input } : input ?? {};
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: JSON.stringify({ title }),
body: JSON.stringify(body),
});
return data.chat;
}
@@ -239,10 +278,61 @@ export async function getSearch(searchId: string) {
return data.search;
}
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",
body: JSON.stringify(body ?? {}),
});
return data.chat;
}
export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
const attachments = (metadata as Record<string, unknown>).attachments;
if (!Array.isArray(attachments)) return [];
const parsed: ChatAttachment[] = [];
for (const entry of attachments) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const record = entry as Record<string, unknown>;
const kind = record.kind;
const id = typeof record.id === "string" ? record.id : "";
const filename = typeof record.filename === "string" ? record.filename : "";
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
dataUrl: record.dataUrl,
} satisfies ChatImageAttachment);
continue;
}
if (kind === "text" && typeof record.text === "string") {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
text: record.text,
truncated: record.truncated === true,
} satisfies ChatTextAttachment);
}
}
return parsed;
}
type RunSearchStreamHandlers = {
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
onSearchError?: (payload: { error: string }) => void;
@@ -252,6 +342,85 @@ type RunSearchStreamHandlers = {
onError?: (payload: { message: string }) => void;
};
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
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 };
}
dispatch(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();
}
export async function runSearchStream(
searchId: string,
body: SearchRunRequest,
@@ -356,6 +525,30 @@ export async function runSearchStream(
flushEvent();
}
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
const headers = new Headers({
Accept: "text/event-stream",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
method: "POST",
headers,
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
if (eventName === "search_results") handlers.onSearchResults?.(payload);
else if (eventName === "search_error") handlers.onSearchError?.(payload);
else if (eventName === "answer") handlers.onAnswer?.(payload);
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
});
}
export async function runCompletion(body: {
chatId: string;
provider: Provider;
@@ -370,7 +563,8 @@ export async function runCompletion(body: {
export async function runCompletionStream(
body: {
chatId: string;
chatId?: string | null;
persist?: boolean;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
@@ -474,3 +668,26 @@ export async function runCompletionStream(
}
flushEvent();
}
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
const headers = new Headers({
Accept: "text/event-stream",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
method: "POST",
headers,
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
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);
});
}

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}