Compare commits

..
Author SHA1 Message Date
buzzert d03b4c4dd7 ios: send from software keyboard 2026-05-02 16:39:39 -07:00
130 changed files with 2388 additions and 22132 deletions
-39
View File
@@ -1,39 +0,0 @@
name: TestFlight
on:
push:
tags:
- "release/ios/v*.*.*"
jobs:
testflight:
name: Build and upload
runs-on: macos-arm64
timeout-minutes: 90
steps:
- name: Check out the release tag
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3.11"
bundler-cache: true
working-directory: ios
- name: Install XcodeGen
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
- name: Build and upload to TestFlight
working-directory: ios
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
CI: "true"
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
run: bundle exec fastlane ios beta
+1 -2
View File
@@ -1,3 +1,2 @@
.env
ios/fastlane/README.md
ios/fastlane/report.xml
-4
View File
@@ -24,10 +24,6 @@ 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
-3
View File
@@ -1,8 +1,5 @@
update_settings(max_parallel_updates=4)
load('ext://dotenv', 'dotenv')
dotenv() # defaults to .env in the current directory
local_resource(
"server",
cmd="npm ci --no-audit --no-fund",
-32
View File
@@ -1,7 +1,6 @@
server {
listen 80;
server_name _;
client_max_body_size 32m;
root /usr/share/nginx/html;
index index.html;
@@ -12,43 +11,12 @@ server {
location /api/ {
proxy_pass http://server:8787/;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri $uri/ /index.html;
}
}
-21
View File
@@ -12,30 +12,9 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_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:-}
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_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
+22 -351
View File
@@ -10,12 +10,6 @@ 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`
@@ -33,95 +27,10 @@ Chat upload limits:
"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 },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }
}
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `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.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
## Chat Tools
### `GET /v1/chat-tools`
- Response:
```json
{
"tools": [
{ "name": "web_search", "description": "..." },
{ "name": "fetch_url", "description": "..." }
]
}
```
Behavior notes:
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
## 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.
## Workspace Items
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
"items": [
{
"type": "chat",
"id": "chat-id",
"title": "optional title",
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": true,
"starredAt": "2026-02-14T01:00:00.000Z",
"initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai",
"lastUsedModel": "gpt-4.1-mini",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
},
{
"type": "search",
"id": "search-id",
"title": "optional title",
"query": "search query",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": false,
"starredAt": null
}
]
}
```
Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars.
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
## Chats
@@ -129,69 +38,14 @@ Behavior notes:
- Response: `{ "chats": ChatSummary[] }`
### `POST /v1/chats`
- Body:
```json
{
"title": "optional title",
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
"model": "optional model id",
"additionalSystemPrompt": "optional stored system prompt",
"enabledTools": ["web_search", "fetch_url"],
"messages": [
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": []
}
]
}
```
- Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- `provider` and `model` must be supplied together when present.
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `POST /v1/chats/:chatId/fork`
- Body: `{ "messageId"?: string }`
- Response: `{ "chat": ChatSummary }`
- Source chat not found: `404 { "message": "chat not found" }`
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
Behavior notes:
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
### `PATCH /v1/chats/:chatId`
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
- Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
- Updating chat fields changes the returned chat's `updatedAt`.
- Not found: `404 { "message": "chat not found" }`
### `PATCH /v1/chats/:chatId/star`
- Body: `{ "starred": boolean }`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }`
Behavior notes:
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the chat transcript or chat `updatedAt`.
### `POST /v1/chats/title/suggest`
- Body:
```json
@@ -203,19 +57,12 @@ Behavior notes:
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`
- Not found: `404 { "message": "chat not found" }`
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }`
@@ -227,34 +74,11 @@ Behavior notes:
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"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": {}
}
```
- 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`
@@ -262,36 +86,11 @@ Notes:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"provider": "openai|anthropic|xai",
"model": "string",
"messages": [
{
"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
}
]
}
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2,
"maxTokens": 256
}
@@ -311,53 +110,14 @@ Notes:
Behavior notes:
- If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- 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.
- 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 `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
- 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 `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
- 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`, `anthropic`, `xai`, and `gemini`: `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), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `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 emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
## Streaming Chat
### `POST /v1/chat-completions/stream`
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
- For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction.
- Available tool calls for chat: `web_search` and `fetch_url`.
- `web_search` uses Exa and returns ranked results with per-result summaries/snippets.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output.
- `anthropic` currently runs without server-managed tool calls.
## Searches
@@ -365,24 +125,8 @@ Behavior notes:
- Response: `{ "searches": SearchSummary[] }`
### `POST /v1/searches`
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
Behavior notes:
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
### `PATCH /v1/searches/:searchId/star`
- Body: `{ "starred": boolean }`
- Body: `{ "title"?: string, "query"?: string }`
- Response: `{ "search": SearchSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the search results or search `updatedAt`.
### `DELETE /v1/searches/:searchId`
- Response: `{ "deleted": true }`
@@ -391,16 +135,6 @@ 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
@@ -417,36 +151,9 @@ 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`
@@ -454,18 +161,12 @@ Behavior notes:
{
"id": "...",
"title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedModel": "string|null"
}
```
@@ -477,56 +178,28 @@ Behavior notes:
"role": "system|user|assistant|tool",
"content": "...",
"name": 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": null
}
```
`metadata` remains nullable. Tool-call log messages still use `metadata.kind = "tool_call"`; regular user messages with attachments use `metadata.attachments`.
`ChatDetail`
```json
{
"id": "...",
"title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"],
"messages": [Message]
}
```
`SearchSummary`
```json
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
```
`SearchDetail`
@@ -537,8 +210,6 @@ Behavior notes:
"query": "...",
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"requestId": "...",
"latencyMs": 123,
"error": null,
+15 -111
View File
@@ -4,13 +4,11 @@ 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)
@@ -20,75 +18,20 @@ Authentication:
```json
{
"chatId": "optional-chat-id",
"persist": true,
"clientRequestId": "optional-client-generated-id",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"provider": "openai|anthropic|xai",
"model": "string",
"messages": [
{
"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
}
]
}
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2,
"maxTokens": 256
}
```
Notes:
- `persist` defaults to `true`.
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- 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.
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- 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.
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
- While running, `GET /v1/active-runs` includes the `chatId`.
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
- 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.
- Backend stores only new non-assistant input history rows to avoid duplicates.
## Event Stream Contract
@@ -98,22 +41,18 @@ Event order:
3. Zero or more `delta`
4. Exactly one terminal event: `done` or `error`
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
### `meta`
```json
{
"type": "meta",
"chatId": "chat-id-or-null",
"callId": "llm-call-id-or-null",
"chatId": "chat-id",
"callId": "llm-call-id",
"provider": "openai",
"model": "gpt-4.1-mini"
}
```
For `persist: false` streams, `chatId` and `callId` are `null`.
### `delta`
```json
@@ -124,19 +63,6 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
### `tool_call`
```json
{
"toolCallId": "call_123",
"name": "web_search",
"status": "initiated",
"summary": "Searching web for 'latest CPI release'.",
"args": { "query": "latest CPI release" },
"startedAt": "2026-03-02T10:00:00.000Z"
}
```
Terminal tool-call event:
```json
{
"toolCallId": "call_123",
@@ -147,12 +73,11 @@ Terminal tool-call event:
"startedAt": "2026-03-02T10:00:00.000Z",
"completedAt": "2026-03-02T10:00:00.820Z",
"durationMs": 820,
"error": null,
"resultPreview": "{\"ok\":true,...}"
}
```
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
### `done`
```json
@@ -177,50 +102,29 @@ Terminal tool-call event:
## Provider Streaming Behavior
- `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.
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks 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.
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
- `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.
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
- `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`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. 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), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `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.
- `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) before producing final text.
- `xai`: same tool-enabled behavior as OpenAI.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
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 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.
- `delta` events stream incrementally as text is generated.
## Persistence + Consistency Model
Backend database remains source of truth.
For persisted streams:
During stream:
- Client may optimistically render accumulated `delta` text.
- Backend emits initiated tool-call events without persisting them.
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
On successful persisted completion:
On successful completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
- Backend then emits `done`.
On persisted failure:
On 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.
-5
View File
@@ -1,5 +0,0 @@
ASC_KEY_ID=
ASC_ISSUER_ID=
ASC_KEY=
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION=
+1 -10
View File
@@ -1,11 +1,2 @@
*.xcodeproj
.env
.env.*
!.env.example
build/
*.ipa
*.dSYM.zip
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
+1 -20
View File
@@ -8,25 +8,8 @@ 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.
## Release Workflow
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
## App Structure
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- Shared iOS app code lives in Swift package:
@@ -52,10 +35,8 @@ 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).
- The iOS client preserves an explicit `/api` base path for proxied deployments.
- Previously saved `/api` API roots are normalized to the server root by the iOS client.
- Provider fallback models:
- OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini`
- Gemini: `gemini-3.5-flash`
- Hermes Agent: `hermes-agent`
-20
View File
@@ -1,20 +0,0 @@
<?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>
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
<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.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+1 -102
View File
@@ -5,110 +5,9 @@ import UIKit
@main
struct SybilApp: App
{
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup(id: SybilCommands.workspaceWindowID) {
WindowGroup {
SplitView()
}
.commands {
SybilCommands()
}
}
}
@MainActor
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
SybilHomeScreenQuickActionHandler.configureQuickActions()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.start()
#endif
return true
}
#if targetEnvironment(macCatalyst)
func applicationWillTerminate(_ application: UIApplication) {
SybilMacQuickQuestionController.shared.stop()
}
#endif
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 targetEnvironment(macCatalyst)
if scene is UIWindowScene, session.role == .windowApplication {
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
}
#endif
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
}
#if targetEnvironment(macCatalyst)
func sceneDidDisconnect(_ scene: UIScene) {
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
}
#endif
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
}
}
+4 -24
View File
@@ -1,9 +1,7 @@
targets:
SybilApp:
type: application
supportedDestinations:
- iOS
- macCatalyst
platform: iOS
deploymentTarget: "18.0"
sources:
- Sources
@@ -11,41 +9,23 @@ targets:
dependencies:
- package: Sybil
product: Sybil
- target: SybilMacQuickQuestion
destinationFilters: [macCatalyst]
embed: true
link: false
codeSign: true
copy:
destination: plugins
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
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
TARGETED_DEVICE_FAMILY: "1,2"
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: "1.13.2"
CURRENT_PROJECT_VERSION: 11
MARKETING_VERSION: 1.0
CURRENT_PROJECT_VERSION: 1
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
schemes:
Sybil:
@@ -1,229 +0,0 @@
import AppKit
import Carbon
// AppKit is unavailable to Catalyst targets. This native bundle uses only
// public AppKit/Carbon APIs and communicates with the client through Foundation.
@MainActor
@objc(SybilMacQuickQuestionPlugin)
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
private var panel: QuickQuestionPanel?
private var panelView: QuickQuestionPanelView?
private var hotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
private var outsideClickMonitor: Any?
private var hotKeyState = SybilQuickQuestionHotKeyState()
private var isDismissing = false
private var shortcutAvailable = false
private var windowlessActivity: NSObjectProtocol?
private var latestState = SybilQuickQuestionPanelState()
private var previousApplication: NSRunningApplication?
required override init() {
super.init()
}
var isVisible: Bool { panel?.isVisible == true }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
self.delegate = delegate
if windowlessActivity == nil {
// Keep the hotkey service alive with no windows. This option does
// not prevent sleep/App Nap or intercept an explicit Quit command.
windowlessActivity = ProcessInfo.processInfo.beginActivity(
options: .automaticTerminationDisabled,
reason: "Quick Question global shortcut"
)
}
guard eventHandler == nil else { return noErr }
var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
]
let context = Unmanaged.passUnretained(self).toOpaque()
let handlerStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, context in
guard let event, let context else { return OSStatus(eventNotHandledErr) }
var identifier = EventHotKeyID()
let status = GetEventParameter(
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
)
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
return OSStatus(eventNotHandledErr)
}
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
// Application event handlers run on the main event loop.
MainActor.assumeIsolated {
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
plugin.delegate?.quickQuestionToggleRequested()
}
}
return noErr
},
eventTypes.count, &eventTypes, context, &eventHandler
)
guard handlerStatus == noErr else { return handlerStatus }
let status = RegisterEventHotKey(
UInt32(kVK_Space), UInt32(optionKey),
EventHotKeyID(signature: 0x53595151, id: 1),
GetApplicationEventTarget(), 0, &hotKey
)
shortcutAvailable = status == noErr
return status
}
func updateState(_ data: Data) {
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
// Updating a background request must not create a non-hosting window.
// The controller creates/orders the panel only after UIKit is foreground.
latestState = state
panelView?.update(state, shortcutAvailable: shortcutAvailable)
if isVisible { resizePanel() }
}
func show() {
createPanelIfNeeded()
guard let panel, let panelView else { return }
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let width = min(680, visibleFrame.width - 48)
panel.setContentSize(NSSize(width: width, height: 240))
panelView.layoutSubtreeIfNeeded()
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
let y = max(visibleFrame.minY + 24, top - height)
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
// The controller has verified UIKit foreground readiness. A
// nonactivating panel can then take focus without raising a workspace.
panel.makeKeyAndOrderFront(nil)
panel.makeFirstResponder(panelView.promptView)
panelView.preparePromptForPresentation()
installOutsideClickMonitor()
}
func hide() {
dismissPanel(restoringPreviousApplication: true)
}
func activateForQuickQuestion() {
if let frontmost = NSWorkspace.shared.frontmostApplication,
frontmost != NSRunningApplication.current {
previousApplication = frontmost
}
// Do not order a window here. Catalyst must finish its own foreground
// transition before the controller calls show().
NSApp.activate()
}
func activateMainWindow() {
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
previousApplication = nil
NSApp.activate()
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
}
private func dismissPanel(restoringPreviousApplication: Bool) {
guard !isDismissing else { return }
let wasVisible = isVisible
isDismissing = true
if wasVisible { panel?.orderOut(nil) }
removeOutsideClickMonitor()
isDismissing = false
let applicationToRestore = previousApplication
previousApplication = nil
if restoringPreviousApplication, NSApp.isActive,
let applicationToRestore, !applicationToRestore.isTerminated {
NSApp.yieldActivation(to: applicationToRestore)
applicationToRestore.activate(options: [])
}
if wasVisible { delegate?.quickQuestionPanelDismissed() }
}
func stop() {
previousApplication = nil
hide()
removeOutsideClickMonitor()
if let hotKey { UnregisterEventHotKey(hotKey) }
if let eventHandler { RemoveEventHandler(eventHandler) }
hotKey = nil
eventHandler = nil
hotKeyState = SybilQuickQuestionHotKeyState()
if let windowlessActivity {
ProcessInfo.processInfo.endActivity(windowlessActivity)
}
windowlessActivity = nil
delegate = nil
}
func windowDidResignKey(_ notification: Notification) {
// Focus is already moving to a window/app the user chose.
dismissPanel(restoringPreviousApplication: false)
}
private func createPanelIfNeeded() {
guard panel == nil else { return }
let panel = QuickQuestionPanel(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
)
panel.title = "Quick Question"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = false
panel.worksWhenModal = true
panel.isReleasedWhenClosed = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isMovableByWindowBackground = true
panel.appearance = NSAppearance(named: .darkAqua)
panel.delegate = self
panel.onDismiss = { [weak self] in self?.hide() }
let view = QuickQuestionPanelView()
view.delegate = delegate
view.onDismiss = { [weak self] in self?.hide() }
panel.contentView = view
panelView = view
self.panel = panel
}
private func resizePanel() {
guard let panel, let panelView else { return }
panelView.layoutSubtreeIfNeeded()
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
guard abs(height - panel.frame.height) > 1 else { return }
var frame = panel.frame
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
frame.size.height = height
panel.setFrame(frame, display: true)
}
private func installOutsideClickMonitor() {
guard outsideClickMonitor == nil else { return }
// Mouse-only monitoring does not require Accessibility or Input
// Monitoring permission. No global keyboard event tap is installed.
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
Task { @MainActor [weak self] in self?.hide() }
}
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
}
@MainActor
private final class QuickQuestionPanel: NSPanel {
var onDismiss: (() -> Void)?
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func cancelOperation(_ sender: Any?) { onDismiss?() }
}
@@ -1,300 +0,0 @@
import AppKit
@MainActor
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var onDismiss: (() -> Void)?
let promptView = QuickQuestionPromptView()
private let stack = NSStackView()
private let answerView = NSTextView()
private let answerScroll = NSScrollView()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let providerPopup = NSPopUpButton()
private let modelPopup = NSPopUpButton()
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
private let sendButton = NSButton()
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
private var panelState = SybilQuickQuestionPanelState()
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
material = .hudWindow
blendingMode = .behindWindow
state = .active
wantsLayer = true
layer?.cornerRadius = 18
layer?.masksToBounds = true
layer?.borderWidth = 1
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.detachesHiddenViews = true
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
])
addRow(makeHeader())
configureTextView(promptView, fontSize: 22)
promptView.delegate = self
promptView.isRichText = false
promptView.allowsUndo = true
promptView.setAccessibilityLabel("Quick question prompt")
promptView.onSubmit = { [weak self] in self?.submit() }
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
let promptScroll = makeScrollView(documentView: promptView)
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
addRow(promptScroll)
configureTextView(answerView, fontSize: 15)
answerView.isEditable = false
answerView.isSelectable = true
answerView.setAccessibilityLabel("Quick question answer")
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
answerScroll.drawsBackground = false
answerScroll.hasVerticalScroller = true
answerScroll.autohidesScrollers = true
answerScroll.documentView = answerView
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
addRow(answerScroll)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.maximumNumberOfLines = 3
addRow(statusLabel)
addRow(makeControls())
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
hint.font = .systemFont(ofSize: 11)
hint.textColor = .secondaryLabelColor
addRow(hint)
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
let previous = panelState
panelState = next
if promptView.string != next.prompt, !promptView.hasMarkedText() {
promptView.string = next.prompt
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
}
promptView.isEditable = !next.isBusy
promptView.needsDisplay = true
if previous.answer != next.answer {
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
}
answerScroll.isHidden = next.answer.isEmpty
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
providerPopup.removeAllItems()
for provider in next.providers {
providerPopup.addItem(withTitle: provider.title)
providerPopup.lastItem?.representedObject = provider.id
}
}
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
providerPopup.selectItem(at: index)
}
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
modelPopup.removeAllItems()
var models = next.models
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
modelPopup.addItems(withTitles: models)
modelPopup.selectItem(withTitle: next.model)
}
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
convertButton.isEnabled = next.canConvert
sendButton.isEnabled = next.canSend
openAppButton.isHidden = next.isAuthenticated
convertButton.isHidden = !next.isAuthenticated
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybils File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}
@@ -1,25 +0,0 @@
targets:
SybilMacQuickQuestion:
type: bundle
platform: macOS
deploymentTarget: "15.0"
sources:
- Sources
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
dependencies:
- sdk: AppKit.framework
- sdk: Carbon.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
PRODUCT_NAME: SybilMacQuickQuestion
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
SUPPORTS_MACCATALYST: NO
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
MACH_O_TYPE: mh_bundle
SKIP_INSTALL: YES
-3
View File
@@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane", "2.237.0"
-244
View File
@@ -1,244 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1274.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.228.1)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
DEPENDENCIES
fastlane (= 2.237.0)
BUNDLED WITH
2.5.23
-33
View File
@@ -1,33 +0,0 @@
{
"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
}
@@ -2,42 +2,10 @@ 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
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() {
public init() {
SybilFontRegistry.registerIfNeeded()
SybilTheme.applySystemAppearance()
}
public var body: some View {
@@ -55,215 +23,19 @@ public struct SplitView: View {
} else if horizontalSizeClass == .compact {
SybilPhoneShellView(viewModel: viewModel)
} else {
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)
NavigationSplitView {
SybilSidebarView(viewModel: viewModel)
.navigationTitle("Sybil")
} detail: {
SybilWorkspaceView(viewModel: viewModel)
}
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
}
}
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
#if !targetEnvironment(macCatalyst)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
#endif
.task {
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
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:
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
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
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
SybilMacQuickQuestionController.shared.show()
#else
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
#endif
}
private func handleQuickQuestionDismissed() {
viewModel.cancelQuickQuestion()
}
}
public struct SybilCommands: Commands {
public static let workspaceWindowID = "sybil-workspace"
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
#if targetEnvironment(macCatalyst)
@Environment(\.openWindow) private var openWindow
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
#endif
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
#if targetEnvironment(macCatalyst)
windowCommands.newChatOrWindow(
newChat: keyboardActions?.newChat,
openWindow: openNewWindow
)
#else
keyboardActions?.newChat()
#endif
}
.keyboardShortcut("n", modifiers: .command)
#if targetEnvironment(macCatalyst)
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
#else
.disabled(keyboardActions == nil)
#endif
#if targetEnvironment(macCatalyst)
Button("New Window", action: openNewWindow)
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
Button("New Search") {
keyboardActions?.newSearch()
}
#if targetEnvironment(macCatalyst)
.keyboardShortcut("n", modifiers: [.command, .option])
#else
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
.disabled(keyboardActions == nil)
#if targetEnvironment(macCatalyst)
Divider()
Button("Quick Question") {
SybilMacQuickQuestionController.shared.toggle()
}
.keyboardShortcut(.space, modifiers: .option)
#endif
}
CommandMenu("Conversation") {
Button("Previous Conversation") {
keyboardActions?.previousConversation()
}
.keyboardShortcut("[", modifiers: .command)
.disabled(keyboardActions == nil)
Button("Next Conversation") {
keyboardActions?.nextConversation()
}
.keyboardShortcut("]", modifiers: .command)
.disabled(keyboardActions == nil)
}
}
#if targetEnvironment(macCatalyst)
private func openNewWindow() {
// Targeting a WindowGroup without a value creates a fresh scene every
// time, even when another workspace window is already open.
openWindow(id: Self.workspaceWindowID)
}
#endif
}
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 }
}
}
@@ -17,18 +17,16 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient: SybilAPIClienting {
actor SybilAPIClient {
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]
@@ -44,26 +42,16 @@ actor SybilAPIClient: SybilAPIClienting {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
}
func listWorkspaceItems() async throws -> [WorkspaceItem] {
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
return response.items
}
func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats
}
func createChat(
title: String? = nil,
provider: Provider? = nil,
model: String? = nil,
messages: [CompletionRequestMessage]? = nil
) async throws -> ChatSummary {
func createChat(title: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats",
method: "POST",
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
body: AnyEncodable(ChatCreateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
@@ -74,26 +62,6 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)",
method: "PATCH",
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -128,26 +96,6 @@ actor SybilAPIClient: SybilAPIClienting {
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 updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
let response = try await request(
"/v1/searches/\(searchID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: SearchCreateResponse.self
)
return response.search
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -156,10 +104,6 @@ actor SybilAPIClient: SybilAPIClienting {
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
@@ -177,35 +121,43 @@ actor SybilAPIClient: SybilAPIClienting {
)
try await stream(request: request) { eventName, dataText in
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
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)
}
}
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,
@@ -224,35 +176,34 @@ actor SybilAPIClient: SybilAPIClienting {
)
try await stream(request: request) { eventName, dataText in
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
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)
}
}
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,
@@ -535,75 +486,6 @@ actor SybilAPIClient: SybilAPIClienting {
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]
@@ -657,33 +539,16 @@ actor SybilAPIClient: SybilAPIClienting {
struct CompletionStreamRequest: Codable, Sendable {
var chatId: String?
var persist: Bool? = nil
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
var userLocation: String? = nil
}
private struct ChatCreateBody: Encodable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?
}
private struct SearchChatCreateBody: Encodable {
var title: String?
}
@@ -1,49 +0,0 @@
import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
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 updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
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 updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
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)
}
}
@@ -1,222 +0,0 @@
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)
}
}
}
@@ -1,36 +0,0 @@
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)")
}
}
@@ -1,354 +0,0 @@
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)
}
}
}
}
@@ -5,26 +5,11 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
@State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = []
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
private var renderItems: [TranscriptRenderItem] {
buildTranscriptRenderItems(from: messages)
}
private var toolCallMessageIDs: Set<String> {
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
}
private var enteringToolCallMessageIDs: Set<String> {
guard hasTrackedToolCallMessages else { return [] }
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
}
private var toolCallMessageIDSignature: String {
toolCallMessageIDs.sorted().joined(separator: "|")
private var hasPendingAssistant: Bool {
messages.contains { message in
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
var body: some View {
@@ -38,103 +23,48 @@ struct SybilChatTranscriptView: View {
.padding(.top, 24)
}
ForEach(renderItems) { item in
switch item {
case let .message(message):
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
ToolCallStackView(
groupID: id,
messages: messages,
entryAnimationIDs: enteringToolCallMessageIDs
)
.frame(maxWidth: .infinity)
.id(id)
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)
}
.id("typing-indicator")
}
Color.clear
.frame(height: 18 + bottomContentInset)
.id(bottomAnchorID)
.frame(height: 2)
.id("chat-bottom-anchor")
}
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
.padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
syncKnownToolCallMessageIDs()
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
.onChange(of: toolCallMessageIDSignature) { _, _ in
syncKnownToolCallMessageIDs()
.onChange(of: messages.map(\.id)) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
}
.onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true)
.onChange(of: isSending) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
}
}
}
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
let action = {
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
}
if animated {
withAnimation(.easeOut(duration: 0.18), action)
} else {
action()
}
}
private func syncKnownToolCallMessageIDs() {
guard !toolCallMessageIDs.isEmpty else { return }
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
hasTrackedToolCallMessages = true
}
}
enum TranscriptRenderItem: Identifiable {
case message(Message)
case toolGroup(id: String, messages: [Message])
var id: String {
switch self {
case let .message(message):
return message.id
case let .toolGroup(id, _):
return "tool-group-\(id)"
}
}
}
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
var items: [TranscriptRenderItem] = []
var toolRun: [Message] = []
func flushToolRun() {
guard !toolRun.isEmpty else { return }
if toolRun.count == 1, let message = toolRun.first {
items.append(.message(message))
} else if let first = toolRun.first {
items.append(.toolGroup(id: first.id, messages: toolRun))
}
toolRun.removeAll(keepingCapacity: true)
}
for message in messages {
if message.toolCallMetadata != nil {
toolRun.append(message)
} else {
flushToolRun()
items.append(.message(message))
}
}
flushToolRun()
return items
}
private struct MessageBubble: View {
@@ -156,8 +86,10 @@ private struct MessageBubble: View {
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
leadingSpacer
HStack(alignment: .top) {
if isUser {
Spacer(minLength: 44)
}
if let toolCallMetadata {
ToolCallActivityChip(
@@ -167,13 +99,6 @@ 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()
@@ -184,7 +109,7 @@ private struct MessageBubble: View {
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
@@ -193,7 +118,6 @@ private struct MessageBubble: View {
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
if isUser {
@@ -212,245 +136,18 @@ private struct MessageBubble: View {
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
}
trailingSpacer
if !isUser {
Spacer(minLength: 0)
}
}
.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 ToolCallStackView: View {
private struct CardLayout {
var x: CGFloat
var y: CGFloat
var scale: CGFloat
var opacity: Double
var zIndex: Double
}
var groupID: String
var messages: [Message]
var entryAnimationIDs: Set<String>
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
private let visibleCollapsedLimit = 4
private let cardHeight: CGFloat = 62
private let expandedGap: CGFloat = 10
private let collapsedStepX: CGFloat = 11
private let collapsedStepY: CGFloat = 10
private let toggleSize: CGFloat = 32
private let toggleGap: CGFloat = 12
private var animation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.34)
}
private var visibleCollapsedCount: Int {
min(messages.count, visibleCollapsedLimit)
}
private var hiddenCount: Int {
max(0, messages.count - visibleCollapsedLimit)
}
private var containerHeight: CGFloat {
if isExpanded {
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
}
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
}
private var accessibilityLabel: String {
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
GeometryReader { geometry in
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
let toggleX = cardWidth + toggleGap
ZStack(alignment: .topLeading) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
let layout = layout(for: index)
let depth = messages.count - index - 1
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
ToolCallStackCard(
message: message,
cardHeight: cardHeight,
compactLayout: true,
animateEntry: shouldAnimateEntry
)
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
.scaleEffect(layout.scale, anchor: .topLeading)
.opacity(layout.opacity)
.offset(x: layout.x, y: layout.y)
.zIndex(layout.zIndex)
.allowsHitTesting(!isHidden)
.accessibilityHidden(isHidden)
}
if !isExpanded && hiddenCount > 0 {
Text("+\(hiddenCount)")
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule()
.fill(Color.black.opacity(0.58))
.overlay(
Capsule()
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
)
)
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
.transition(.opacity)
}
Button {
withAnimation(animation) {
isExpanded.toggle()
}
} label: {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.frame(width: toggleSize, height: toggleSize)
.background(
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
Circle()
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
)
}
.buttonStyle(.plain)
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
.offset(x: toggleX, y: 8)
.zIndex(Double(messages.count + 2))
}
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
.animation(animation, value: isExpanded)
}
.frame(height: containerHeight)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func layout(for index: Int) -> CardLayout {
if isExpanded {
return CardLayout(
x: 0,
y: CGFloat(index) * (cardHeight + expandedGap),
scale: 1,
opacity: 1,
zIndex: Double(messages.count - index)
)
}
let depth = messages.count - index - 1
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
let isHidden = depth >= visibleCollapsedLimit
return CardLayout(
x: CGFloat(visibleDepth) * collapsedStepX,
y: CGFloat(visibleDepth) * collapsedStepY,
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
)
}
}
private struct ToolCallStackCard: View {
var message: Message
var cardHeight: CGFloat
var compactLayout: Bool
var animateEntry: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var entryAnimationArmed = false
@State private var didEnter = false
private var isPreparingEntry: Bool {
(animateEntry || entryAnimationArmed) && !didEnter
}
var body: some View {
Group {
if let metadata = message.toolCallMetadata {
ToolCallActivityChip(
metadata: metadata,
fallbackContent: message.content,
createdAt: message.createdAt,
compactLayout: compactLayout
)
}
}
.frame(height: cardHeight, alignment: .top)
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
.offset(y: isPreparingEntry ? -8 : 0)
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
.opacity(isPreparingEntry ? 0.72 : 1)
.onAppear {
guard !didEnter, !entryAnimationArmed else { return }
guard animateEntry else {
didEnter = true
return
}
entryAnimationArmed = true
if reduceMotion {
didEnter = true
} else {
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
didEnter = true
}
}
}
}
}
private struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
case failed
}
var metadata: ToolCallMetadata
var fallbackContent: String
var createdAt: Date
var compactLayout: Bool = false
private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
@@ -494,22 +191,11 @@ private struct ToolCallActivityChip: View {
}
private var isFailed: Bool {
visualState == .failed
}
private var visualState: VisualState {
switch (metadata.status ?? "").lowercased() {
case "failed":
return .failed
case "initiated":
return .initiated
default:
return .completed
}
(metadata.status ?? "").lowercased() == "failed"
}
private var detailLabel: String {
var pieces: [String] = [stateLabel]
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
if let durationMs = metadata.durationMs, durationMs > 0 {
pieces.append("\(durationMs) ms")
}
@@ -521,14 +207,14 @@ private struct ToolCallActivityChip: View {
HStack(alignment: .top, spacing: 11) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill(iconColor.opacity(0.13))
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
.overlay(
RoundedRectangle(cornerRadius: 9)
.stroke(iconColor.opacity(0.34), lineWidth: 1)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
)
Image(systemName: iconName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(iconColor)
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
}
.frame(width: 30, height: 30)
@@ -537,14 +223,12 @@ private struct ToolCallActivityChip: View {
.font(.sybil(.subheadline))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
.lineSpacing(3)
.lineLimit(compactLayout ? 1 : nil)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: !compactLayout)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Text(toolLabel)
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(iconColor.opacity(0.90))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
.lineLimit(1)
Text(detailLabel)
@@ -554,50 +238,16 @@ private struct ToolCallActivityChip: View {
}
}
}
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(backgroundGradient)
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(iconColor.opacity(0.34), lineWidth: 1)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
)
)
.frame(maxWidth: 520, alignment: .leading)
}
private var stateLabel: String {
switch visualState {
case .failed:
return "Failed"
case .initiated:
return "Running"
case .completed:
return "Completed"
}
}
private var iconColor: Color {
switch visualState {
case .failed:
return SybilTheme.danger
case .initiated:
return SybilTheme.warning
case .completed:
return SybilTheme.accent
}
}
private var backgroundGradient: LinearGradient {
switch visualState {
case .failed:
return SybilTheme.failedToolCallGradient
case .initiated:
return SybilTheme.runningToolCallGradient
case .completed:
return SybilTheme.toolCallGradient
}
}
}
@@ -1,71 +0,0 @@
import Foundation
// This Foundation-only contract is compiled into both the Catalyst client and the
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
@MainActor
@objc(SybilQuickQuestionPanelBridging)
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
init()
var isVisible: Bool { get }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
func updateState(_ data: Data)
func show()
func hide()
func activateForQuickQuestion()
func activateMainWindow()
func stop()
}
@MainActor
@objc(SybilQuickQuestionPanelDelegate)
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
func quickQuestionToggleRequested()
func quickQuestionPromptChanged(_ prompt: String)
func quickQuestionSubmitRequested()
func quickQuestionProviderChanged(_ provider: String)
func quickQuestionModelChanged(_ model: String)
func quickQuestionConvertRequested()
func quickQuestionOpenAppRequested()
func quickQuestionPanelDismissed()
}
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
struct ProviderOption: Codable, Equatable, Sendable {
var id: String
var title: String
}
var prompt = ""
var answer = ""
var toolSummaries: [String] = []
var provider = ""
var providers: [ProviderOption] = []
var model = ""
var models: [String] = []
var isSending = false
var isConverting = false
var canSend = false
var canConvert = false
var isAuthenticated = false
var isCheckingSession = true
var error: String?
var isBusy: Bool { isSending || isConverting }
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
}
// Carbon can deliver multiple presses while a key is held. Consume one toggle
// per press/release pair, without suppressing a second deliberate press.
struct SybilQuickQuestionHotKeyState {
private var isPressed = false
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
if !isKeyDown {
isPressed = false
return false
}
guard !isPressed else { return false }
isPressed = true
return true
}
}
@@ -1,257 +0,0 @@
#if targetEnvironment(macCatalyst)
import Combine
import Foundation
import Observation
import UIKit
@MainActor
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
public static let shared = SybilMacQuickQuestionController()
private var pluginBundle: Bundle?
private var bridge: (any SybilQuickQuestionPanelBridging)?
private var viewModel: SybilViewModel?
private var observationGeneration = 0
private var isPresentationRequested = false
private var activationRequested = false
private var presentationTask: Task<Void, Never>?
private var foregroundObservation: AnyCancellable?
private var notificationCenter = NotificationCenter.default
private var isApplicationInForeground: @MainActor () -> Bool = {
UIApplication.shared.applicationState != .background
}
private override init() {
super.init()
}
// Inject foreground readiness as well as the panel so tests can exercise
// windowless activation without activating apps or showing real windows.
init(
bridge: any SybilQuickQuestionPanelBridging,
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
notificationCenter: NotificationCenter = NotificationCenter()
) {
self.bridge = bridge
self.isApplicationInForeground = isApplicationInForeground
self.notificationCenter = notificationCenter
super.init()
_ = bridge.start(delegate: self)
observeForegroundTransitionsIfNeeded()
}
public func start() {
observeForegroundTransitionsIfNeeded()
guard bridge == nil else { return }
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
let bundle = Bundle(url: url)
else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
return
}
do {
try bundle.loadAndReturnError()
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
return
}
let plugin = pluginClass.init()
pluginBundle = bundle
bridge = plugin
let status = plugin.start(delegate: self)
if status != 0 {
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
}
} catch {
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
}
}
func attach(_ viewModel: SybilViewModel) {
guard self.viewModel !== viewModel else { return }
// A window becoming active must not replace an in-flight quick question.
if self.viewModel != nil,
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
return
}
self.viewModel = viewModel
if bridge?.isVisible == true { updateAndObserve() }
}
public func toggle() {
start()
if isPresentationRequested || bridge?.isVisible == true {
cancelPresentation()
bridge?.hide()
} else {
show()
}
}
public func show() {
start()
isPresentationRequested = true
schedulePresentationIfNeeded()
}
public func stop() {
cancelPresentation()
foregroundObservation = nil
viewModel?.cancelQuickQuestion()
bridge?.stop()
bridge = nil
// Keep the loaded bundle alive for the lifetime of its code.
}
private func observeForegroundTransitionsIfNeeded() {
guard foregroundObservation == nil else { return }
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
.sink { [weak self] _ in
Task { @MainActor [weak self] in
self?.schedulePresentationIfNeeded()
}
}
}
private func schedulePresentationIfNeeded() {
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
// Leave the Carbon/NSApplication event callback before touching windows.
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
// is not a substitute for checking UIKit's foreground lifecycle state.
presentationTask = Task { @MainActor [weak self] in
guard let self, !Task.isCancelled else { return }
self.presentationTask = nil
guard self.isPresentationRequested else { return }
guard self.isApplicationInForeground() else {
guard !self.activationRequested else { return }
self.activationRequested = true
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
self.bridge?.activateForQuickQuestion()
// Also handle an activation that completes synchronously. If it
// has not completed, subsequent UIKit notifications resume us.
self.schedulePresentationIfNeeded()
return
}
self.activationRequested = false
self.updateAndObserve()
self.bridge?.show()
}
}
private func cancelPresentation() {
isPresentationRequested = false
activationRequested = false
presentationTask?.cancel()
presentationTask = nil
observationGeneration += 1
}
private func updateAndObserve() {
guard let bridge else { return }
observationGeneration += 1
let generation = observationGeneration
let state = withObservationTracking {
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
self.updateAndObserve()
}
}
do {
bridge.updateState(try JSONEncoder().encode(state))
} catch {
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
}
}
func quickQuestionToggleRequested() {
toggle()
}
func quickQuestionPromptChanged(_ prompt: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.updateQuickQuestionPrompt(prompt)
updateAndObserve()
}
func quickQuestionSubmitRequested() {
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
viewModel.sendQuickQuestion()
updateAndObserve()
}
func quickQuestionProviderChanged(_ provider: String) {
guard let viewModel, let provider = Provider(rawValue: provider),
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionProvider(provider)
updateAndObserve()
}
func quickQuestionModelChanged(_ model: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionModel(model)
updateAndObserve()
}
func quickQuestionConvertRequested() {
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if await viewModel.convertQuickQuestionToChat() {
self.cancelPresentation()
self.openMainWindow()
self.bridge?.hide()
} else {
self.updateAndObserve()
}
}
}
func quickQuestionOpenAppRequested() {
cancelPresentation()
openMainWindow()
bridge?.hide()
}
private func openMainWindow() {
let session = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.session
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
bridge?.activateMainWindow()
}
func quickQuestionPanelDismissed() {
// Toggling out preserves the draft and lets an answer finish. Reopening
// reads the latest state, without starting a new request.
cancelPresentation()
}
}
extension SybilViewModel {
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
SybilQuickQuestionPanelState(
prompt: quickQuestionPrompt,
answer: quickQuestionAnswerText,
toolSummaries: quickQuestionMessages.compactMap { message in
guard let metadata = message.toolCallMetadata else { return nil }
return metadata.summary ?? message.content
},
provider: quickQuestionProvider.rawValue,
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
model: quickQuestionModel,
models: quickQuestionProviderModelOptions,
isSending: isQuickQuestionSending,
isConverting: isConvertingQuickQuestion,
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
canConvert: canConvertQuickQuestion,
isAuthenticated: isAuthenticated,
isCheckingSession: isCheckingSession,
error: quickQuestionError
)
}
}
#endif
@@ -1,37 +0,0 @@
#if targetEnvironment(macCatalyst)
import Combine
// Scene lifetime is independent of focused commands: a connected window may
// still be authenticating, inactive, or minimized. The native Quick Question
// panel is not a workspace scene and does not count as an open window here.
@MainActor
public final class SybilMacWindowCommands: ObservableObject {
public static let shared = SybilMacWindowCommands()
@Published private var connectedWindowIDs: Set<String> = []
init() {}
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
public func windowConnected(sessionID: String) {
connectedWindowIDs.insert(sessionID)
}
public func windowDisconnected(sessionID: String) {
connectedWindowIDs.remove(sessionID)
}
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
!hasOpenWindows || hasFocusedActions
}
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
if hasOpenWindows {
newChat?()
} else {
openWindow()
}
}
}
#endif
@@ -10,7 +10,6 @@ extension Theme {
.text {
FontFamily(.custom("Inter"))
FontSize(15)
ForegroundColor(SybilTheme.text)
}
.code {
FontFamilyVariant(.monospaced)
@@ -85,7 +84,7 @@ extension Theme {
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.46))
.relativeLineSpacing(.em(0.36))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in
@@ -4,16 +4,12 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai
case anthropic
case xai
case gemini
case hermesAgent = "hermes-agent"
public var displayName: String {
switch self {
case .openai: return "OpenAI"
case .anthropic: return "Anthropic"
case .xai: return "xAI"
case .gemini: return "Gemini"
case .hermesAgent: return "Hermes Agent"
}
}
}
@@ -25,312 +21,23 @@ 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?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
}
extension ChatSummary {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
}
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
case chat
case search
}
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var type: WorkspaceItemType
public var id: String
public var title: String?
public var query: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
public init(chat: ChatSummary) {
self.type = .chat
self.id = chat.id
self.title = chat.title
self.query = nil
self.parentChatId = chat.parentChatId
self.titleGenerationPending = chat.titleGenerationPending
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.starred = chat.starred
self.starredAt = chat.starredAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
self.lastUsedModel = chat.lastUsedModel
}
public init(search: SearchSummary) {
self.type = .search
self.id = search.id
self.title = search.title
self.query = search.query
self.parentChatId = nil
self.titleGenerationPending = false
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.starred = search.starred
self.starredAt = search.starredAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
self.lastUsedModel = nil
}
public var chatSummary: ChatSummary? {
guard type == .chat else { return nil }
return ChatSummary(
id: id,
title: title,
parentChatId: parentChatId,
titleGenerationPending: titleGenerationPending,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
lastUsedModel: lastUsedModel
)
}
public var searchSummary: SearchSummary? {
guard type == .search else { return nil }
return SearchSummary(
id: id,
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt
)
}
}
extension WorkspaceItem {
private enum CodingKeys: String, CodingKey {
case type
case id
case title
case query
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(WorkspaceItemType.self, forKey: .type)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
query = try container.decodeIfPresent(String.self, forKey: .query)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct Message: Codable, Identifiable, Hashable, Sendable {
@@ -341,10 +48,6 @@ 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,
@@ -452,31 +155,13 @@ 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 {
public var id: String
public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -484,41 +169,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var messages: [Message]
}
extension ChatDetail {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
case messages
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
messages = try container.decode([Message].self, forKey: .messages)
}
}
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
@@ -550,8 +200,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var requestId: String?
public var latencyMs: Int?
public var error: String?
@@ -562,16 +210,6 @@ 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?
@@ -601,19 +239,17 @@ 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, attachments: [ChatAttachment]? = nil) {
public init(role: MessageRole, content: String, name: String? = 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
}
@@ -633,8 +269,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
public var summary: String
public var args: [String: JSONValue]
public var startedAt: String
public var completedAt: String?
public var durationMs: Int?
public var completedAt: String
public var durationMs: Int
public var error: String?
public var resultPreview: String?
}
@@ -730,10 +366,6 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary]
}
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
struct ChatDetailResponse: Codable {
var chat: ChatDetail
}
@@ -22,473 +22,29 @@ enum PhoneRoute: Hashable {
struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel
@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
}
@State private var path: [PhoneRoute] = []
var body: some View {
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)
}
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)
}
}
.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
var highlightedSelection: SidebarSelection?
var onSelect: (SidebarSelection) -> Void
var onRoute: (PhoneRoute) -> Void
@Binding var path: [PhoneRoute]
var body: some View {
VStack(spacing: 0) {
@@ -504,15 +60,50 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border)
}
SybilSidebarItemList(
viewModel: viewModel,
isSelected: { item in
highlightedSelection == item.selection
},
onSelect: { item in
onSelect(item.selection)
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
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) {
@@ -527,20 +118,19 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
viewModel.openSettings()
onRoute(.settings)
path = [.settings]
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
viewModel.startNewSearch()
onRoute(.draftSearch)
path = [.draftSearch]
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
viewModel.startNewChat()
onRoute(.draftChat)
path = [.draftChat]
}
}
.padding(.horizontal, 18)
@@ -578,40 +168,79 @@ private struct SybilPhoneSidebarRoot: View {
}
}
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
private struct SybilPhoneSidebarRow: View {
var item: SidebarItem
var body: some View {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
applyRoute()
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
let route: PhoneRoute
var body: some View {
SybilWorkspaceView(viewModel: viewModel)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationBarTitleDisplayMode(.inline)
.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()
@@ -1,19 +0,0 @@
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
}
}
@@ -1,306 +0,0 @@
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
@State private var promptSelection: TextSelection?
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
let prompt = viewModel.quickQuestionPrompt
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
}
}
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) }
),
selection: $promptSelection,
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() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = 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")."
}
}
@@ -1,35 +0,0 @@
import SwiftUI
// A vertical TextField treats Return as editing, not form submission. Handle
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
// keys to the native text field. iOS keeps its software-keyboard behavior.
@MainActor
enum SybilReturnKeySubmission {
static func handle(
phase: KeyPress.Phases,
modifiers: EventModifiers,
submit: () -> Void
) -> KeyPress.Result {
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
return .ignored
}
if phase == .down {
submit()
}
// Consume autorepeat without submitting or inserting a newline.
return .handled
}
}
extension View {
@ViewBuilder
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
#if targetEnvironment(macCatalyst)
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
}
#else
self
#endif
}
}
@@ -5,62 +5,23 @@ 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: 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)
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)
}
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)
}
Text(resultCountLabel)
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
}
}
@@ -98,11 +59,9 @@ struct SybilSearchResultsView: View {
.foregroundStyle(SybilTheme.danger)
}
}
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
.padding(.vertical, 20)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -117,13 +76,6 @@ 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) {
@@ -11,14 +11,6 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
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 quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
}
private let defaults: UserDefaults
@@ -27,8 +19,6 @@ 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
@@ -42,23 +32,10 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider
let preferredModels: [Provider: String] = [
self.preferredModelByProvider = [
.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",
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
.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",
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
]
}
@@ -76,15 +53,6 @@ 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[.gemini], forKey: Keys.preferredGeminiModel)
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[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
}
var trimmedTokenOrNil: String? {
@@ -100,10 +68,15 @@ final class SybilSettingsStore {
raw.removeLast()
}
guard let components = URLComponents(string: raw) else {
guard var components = URLComponents(string: raw) else {
return nil
}
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if path.lowercased() == "api" {
components.path = ""
}
return components.url
}
}
@@ -4,6 +4,13 @@ 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
}
@@ -11,6 +18,8 @@ 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",
@@ -50,34 +59,121 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border)
}
SybilSidebarItemList(
viewModel: viewModel,
isSelected: isSelected,
onSelect: { item in
viewModel.select(item.selection)
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 {
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(
@@ -106,206 +202,3 @@ struct SybilSidebarView: View {
.buttonStyle(.plain)
}
}
struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
@State private var renameTarget: SidebarItem?
@State private var renameTitle = ""
private var isRenameAlertPresented: Binding<Bool> {
Binding {
renameTarget != nil
} set: { isPresented in
if !isPresented {
renameTarget = nil
renameTitle = ""
}
}
}
var body: some View {
Group {
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 {
Task {
await viewModel.setItemStarred(item.selection, starred: !item.starred)
}
} label: {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
if item.kind == .chat {
Button {
renameTarget = item
renameTitle = item.title
} label: {
Label("Rename", systemImage: "pencil")
}
}
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
.refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
}
}
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
TextField("Title", text: $renameTitle)
Button("Cancel", role: .cancel) {
renameTarget = nil
renameTitle = ""
}
Button("Save") {
let target = renameTarget
let title = renameTitle
renameTarget = nil
renameTitle = ""
if let target, case let .chat(chatID) = target.selection {
Task {
await viewModel.renameChat(chatID: chatID, title: title)
}
}
}
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
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)
if item.starred {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.yellow)
}
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")
}
}
@@ -1,7 +1,6 @@
import CoreText
import Foundation
import SwiftUI
import UIKit
enum SybilFontRegistry {
static func registerIfNeeded() {
@@ -9,7 +8,7 @@ enum SybilFontRegistry {
}
private static let registeredFonts: Void = {
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
for fontName in ["Inter", "Orbitron"] {
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
Bundle.main.url(forResource: fontName, withExtension: "ttf")
else {
@@ -64,10 +63,6 @@ extension Font {
}
}
enum SybilLayout {
static let webContentMaxWidth: CGFloat = 896
}
enum SybilTheme {
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
@@ -82,24 +77,6 @@ enum SybilTheme {
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
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)
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
@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(
@@ -183,19 +160,8 @@ enum SybilTheme {
static var toolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.01, green: 0.15, blue: 0.17),
Color(red: 0.03, green: 0.09, blue: 0.15)
],
startPoint: .leading,
endPoint: .trailing
)
}
static var runningToolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.30, green: 0.19, blue: 0.04),
Color(red: 0.09, green: 0.05, blue: 0.17)
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
],
startPoint: .leading,
endPoint: .trailing
@@ -205,8 +171,8 @@ enum SybilTheme {
static var failedToolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.27, green: 0.04, blue: 0.10),
Color(red: 0.15, green: 0.03, blue: 0.07)
danger.opacity(0.18),
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
],
startPoint: .leading,
endPoint: .trailing
@@ -219,7 +185,7 @@ struct SybilWordmark: View {
var body: some View {
Text("SYBIL")
.font(.custom("Stalinist One", size: size))
.font(.custom("Orbitron", 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
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
# Sybil for iOS and Mac Catalyst
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
release/archive signing settings are unchanged.
## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick
Question panel, including over other applications. The prompt is focused as soon
as it opens, with any existing prompt selected so typing replaces it. The same
select-on-open behavior applies to Quick Question on iPhone and iPad.
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace.
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
build opts out of automatic termination, and the native shortcut service holds a
matching automatic-termination activity until it stops. No hidden keep-alive
window or sleep-preventing activity is used. Mac scene support also allows the
workspace to reopen after its last window is closed.
When Catalyst is backgrounded, opening Quick Question first requests app
activation and waits for UIKit to enter the foreground before creating or ordering
the native panel. Presentation is deferred out of the hotkey event callback.
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
that required activation returns focus to the previous app; explicitly opening
the workspace keeps focus in Sybil.
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
workspace window when none are open. **Command+Shift+N** always opens a new window,
including while signed out. **Command+Option+N** starts a new search; iOS retains
its existing Command+Shift+N search shortcut.
The shortcut is also shown under **File → Quick Question**. If another application
already owns Option+Space, the panel reports that the shortcut is unavailable;
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
needed for the registered hotkey.
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
provider/model preferences, streaming, and conversion to chat remain in the
existing `SybilViewModel`; there is no separate backend or API contract. The panel
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
Catalyst process.
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
action, and does not build or embed the macOS helper.
## Verification
- `just test`: existing Swift package tests on the iPhone simulator.
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
- For a manual Mac check, open the panel from another app, type immediately,
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
returns input to the previous app. Check a second display and a full-screen Space
when available.
- Close every Mac window, invoke Option+Space, and verify the saved question and
answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window.
- With all workspace windows closed, switch to another app and repeatedly open
and dismiss Quick Question, including two quick Option+Space presses while it
is opening. Check that no late panel appears after cancellation and that no
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
-2
View File
@@ -1,2 +0,0 @@
app_identifier("net.buzzert.sybil2")
team_id("DQQH5H6GBD")
-47
View File
@@ -1,47 +0,0 @@
default_platform(:ios)
platform :ios do
desc "Build a release tag and upload it to TestFlight"
lane :beta do
setup_ci
match(type: "appstore")
tag = ENV.fetch("GITHUB_REF_NAME")
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
ios_dir = File.expand_path("..", __dir__)
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
contents = File.read(project_spec)
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
UI.user_error!("Could not find version settings in #{project_spec}")
end
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
File.write(project_spec, contents)
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_content: ENV.fetch("ASC_KEY"),
is_key_content_base64: true
)
build_app(
project: File.join(ios_dir, "Sybil.xcodeproj"),
scheme: "Sybil"
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
end
end
-7
View File
@@ -1,7 +0,0 @@
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
storage_mode("git")
type("appstore")
app_identifier(["net.buzzert.sybil2"])
team_id("DQQH5H6GBD")
profile_name("Sybil AppStore CI")
+4 -32
View File
@@ -1,38 +1,10 @@
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
simulator_name := "iPhone 16e"
derived_data := "build/DerivedData"
default:
@just build
generate:
xcodegen --spec project.yml
build: generate
build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
fi
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
run: generate
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
build-mac: generate
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
run-mac: build-mac
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
beta:
fastlane ios beta
screenshot path="build/sybil-screenshot.png":
mkdir -p "$(dirname '{{path}}')"
xcrun simctl io booted screenshot '{{path}}'
-1
View File
@@ -7,4 +7,3 @@ packages:
path: Packages/Sybil
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
Binary file not shown.
Binary file not shown.
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
LOG_DIR="$ROOT/.devbox/logs"
PID_DIR="$ROOT/.devbox/pids"
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env OPENAI_API_KEY
require_env EXA_API_KEY
mkdir -p "$LOG_DIR" "$PID_DIR"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8787}"
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
echo "Installing server dependencies..."
npm ci --prefix "$ROOT/server" --no-audit --no-fund
echo "Installing web dependencies..."
npm ci --prefix "$ROOT/web" --no-audit --no-fund
if command -v tmux >/dev/null 2>&1; then
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
tmux set-environment -t "$SESSION" HOST "$HOST"
tmux set-environment -t "$SESSION" PORT "$PORT"
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
fi
for name in \
CHAT_CODEX_TOOL_ENABLED \
CHAT_CODEX_REMOTE_HOST \
CHAT_CODEX_REMOTE_USER \
CHAT_CODEX_REMOTE_PORT \
CHAT_CODEX_REMOTE_WORKDIR \
CHAT_CODEX_SSH_KEY_PATH \
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
CHAT_CODEX_EXEC_TIMEOUT_MS \
CHAT_SHELL_TOOL_ENABLED \
CHAT_SHELL_EXEC_TIMEOUT_MS; do
if [[ -n "${!name:-}" ]]; then
tmux set-environment -t "$SESSION" "$name" "${!name}"
fi
done
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
"npm run dev -- --host 0.0.0.0 --port 5173"
echo "Started Sybil in tmux session '$SESSION'."
echo "Server: http://127.0.0.1:8787"
echo "Web: http://127.0.0.1:5173"
exit 0
fi
echo "tmux not found; starting background processes with nohup."
if [[ -f "$PID_DIR/server.pid" ]]; then
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
fi
if [[ -f "$PID_DIR/web.pid" ]]; then
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
fi
(
cd "$ROOT/server"
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
) >"$LOG_DIR/server.log" 2>&1 &
echo "$!" >"$PID_DIR/server.pid"
(
cd "$ROOT/web"
exec npm run dev -- --host 0.0.0.0 --port 5173
) >"$LOG_DIR/web.log" 2>&1 &
echo "$!" >"$PID_DIR/web.pid"
echo "Started Sybil background processes."
echo "Server log: $LOG_DIR/server.log"
echo "Web log: $LOG_DIR/web.log"
-357
View File
@@ -1,357 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="${REPO:-Sybil-2}"
WORKLOAD="${WORKLOAD:-sybil-dev}"
CLUSTER="${CLUSTER:-viper}"
NAMESPACE="${NAMESPACE:-buzzert}"
QUOTA="${QUOTA:-research-api}"
SECRET="${SECRET:-sybil-dev-env}"
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
INGRESS_PORT="${INGRESS_PORT:-5173}"
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
brix create \
--project-root "$(dirname "$ROOT")" \
--path "$REPO=$ROOT" \
--repositories "$REPO" \
--include-untracked \
--reconcile-policy continuous \
-- \
"cluster=$CLUSTER" \
"namespace=$NAMESPACE" \
"name=$WORKLOAD" \
"pool.spec.quota=$QUOTA" \
"pool.spec.size.resource=cpu" \
"pool.spec.size.quantity=1" \
"pool.spec.priority=low"
if [[ "$REPO" != "openai" ]]; then
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
fi
fi
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
exit 1
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
--from-file=id_ed25519="$tmpdir/id_ed25519" \
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
--arg host "$CODEX_SERVICE" \
--arg port "$CODEX_SSH_PORT" \
--arg workdir "$CODEX_WORKDIR" \
--arg keyPath "$CODEX_SSH_KEY_PATH" \
'{
stringData: {
CHAT_CODEX_TOOL_ENABLED: "true",
CHAT_CODEX_REMOTE_HOST: $host,
CHAT_CODEX_REMOTE_USER: "root",
CHAT_CODEX_REMOTE_PORT: $port,
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
CHAT_SHELL_TOOL_ENABLED: "true"
}
}')"
ensure_codex_deployment() {
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
apiVersion: apps/v1
kind: Deployment
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: $CODEX_SERVICE
template:
metadata:
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
tolerations:
- key: openai.com/team
operator: Exists
effect: NoSchedule
containers:
- name: $CODEX_CONTAINER
image: $CODEX_IMAGE
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v sshd >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
rm -rf /var/lib/apt/lists/*
fi
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex
fi
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
{
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
fi
if [[ -n "\${EXA_API_KEY:-}" ]]; then
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
fi
} >/root/.sybil-codex-env
chmod 600 /root/.sybil-codex-env
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
#!/usr/bin/env bash
set -euo pipefail
if [[ -f /root/.sybil-codex-env ]]; then
source /root/.sybil-codex-env
fi
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
fi
exec /bin/bash -l
SSH_COMMAND
chmod 700 /usr/local/bin/sybil-codex-ssh-command
ssh-keygen -A
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
Port $CODEX_SSH_PORT
ListenAddress 0.0.0.0
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
AcceptEnv OPENAI_API_KEY EXA_API_KEY
AuthorizedKeysFile .ssh/authorized_keys
PermitUserEnvironment no
ForceCommand /usr/local/bin/sybil-codex-ssh-command
AllowTcpForwarding no
X11Forwarding no
Subsystem sftp internal-sftp
PidFile /run/sshd/sybil-codex.pid
SSHD_CONFIG
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
ports:
- name: ssh
containerPort: $CODEX_SSH_PORT
envFrom:
- secretRef:
name: $SECRET
resources:
requests:
cpu: "1"
memory: 4Gi
limits:
cpu: "1"
memory: 4Gi
volumeMounts:
- name: sybil-codex-ssh
mountPath: /run/secrets/sybil-codex-ssh
readOnly: true
- name: sybil-codex-workspace
mountPath: /workspace
volumes:
- name: sybil-codex-ssh
secret:
secretName: $CODEX_SSH_SECRET
defaultMode: 384
- name: sybil-codex-workspace
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: $CODEX_SERVICE
ports:
- name: ssh
port: $CODEX_SSH_PORT
targetPort: ssh
KUBE_MANIFEST
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
}
ensure_codex_deployment
ensure_env_from() {
local resource="$1"
local name="$2"
local jsonpath="$3"
local patch_path="$4"
local existing_env_from
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
return
fi
local patch
if [[ -z "$existing_env_from" ]]; then
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
else
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
}
ensure_env_from \
workload \
"$WORKLOAD" \
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
ensure_env_from \
pool \
"$WORKLOAD" \
"{.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/workers/template/spec/containers/0/envFrom"
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
name: "sybil-codex-ssh",
secret: {
secretName: $secret,
defaultMode: 384
}
}')"
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
patch_pool_codex_mount() {
local pool_json containers volumes patch
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
containers="$(jq \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
(.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName))
' <<<"$pool_json")"
volumes="$(jq \
--argjson sshVolume "$codex_ssh_volume_json" \
'
(.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume]
' <<<"$pool_json")"
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
spec: {
workers: {
template: {
spec: {
containers: $containers,
volumes: $volumes
}
}
}
}
}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount() {
local workload_json pools patch
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
pools="$(jq \
--arg groupName "$WORKLOAD" \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
--argjson sshVolume "$codex_ssh_volume_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
.spec.pools
| map(
if .groupName == $groupName then
.template.template.spec.workers.template.spec.containers =
((.template.template.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName)))
| .template.template.spec.workers.template.spec.volumes =
((.template.template.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume])
else
.
end
)
' <<<"$workload_json")"
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount
patch_pool_codex_mount
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
deadline=$((SECONDS + 300))
while true; do
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
break
fi
if (( SECONDS >= deadline )); then
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
exit 1
fi
sleep 5
done
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
echo "Devbox update complete: $url"
+1 -19
View File
@@ -1,7 +1,7 @@
# Sybil Server
Backend API for:
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
- LLM multiplexer (OpenAI / Anthropic / xAI (Grok))
- Personal chat database (chats/messages + LLM call log)
## Stack
@@ -43,25 +43,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
- `XAI_API_KEY`
- `GEMINI_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`
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported 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 managed-tool providers)
- `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 managed-tool providers on the same devbox)
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
## API
- `GET /health`
-1
View File
@@ -11,7 +11,6 @@
"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",
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
@@ -1,44 +0,0 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'folder',
"title" TEXT NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ProjectItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL,
"chatId" TEXT,
"searchId" TEXT,
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
);
-- CreateIndex
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
-- CreateIndex
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
-- CreateIndex
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
-- CreateIndex
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
@@ -1,8 +0,0 @@
-- Add normalized search query lookup key for cache/reuse behavior.
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
UPDATE "Search"
SET "queryNormalized" = lower(trim("query"))
WHERE "query" IS NOT NULL AND trim("query") != '';
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
@@ -1,5 +0,0 @@
-- Add durable root grouping and one-time fork title generation state.
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
+1 -60
View File
@@ -13,8 +13,6 @@ enum Provider {
openai
anthropic
xai
gemini
hermes_agent @map("hermes-agent")
}
enum MessageRole {
@@ -28,11 +26,6 @@ enum SearchSource {
exa
}
enum ProjectKind {
starred
folder
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -43,7 +36,6 @@ model User {
chats Chat[]
searches Search[]
projects Project[]
}
model Chat {
@@ -51,31 +43,20 @@ model Chat {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String?
titleGenerationPending Boolean @default(false)
title String?
initiatedProvider Provider?
initiatedModel String?
lastUsedProvider Provider?
lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id])
userId String?
// Forks always point directly to the single root chat, never to another fork.
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
parentChatId String?
childChats Chat[] @relation("ChatForks")
messages Message[]
calls LlmCall[]
projectItems ProjectItem[]
@@index([userId])
@@index([parentChatId])
}
model Message {
@@ -129,7 +110,6 @@ model Search {
title String?
query String?
queryNormalized String?
source SearchSource @default(exa)
@@ -148,10 +128,8 @@ model Search {
userId String?
results SearchResult[]
projectItems ProjectItem[]
@@index([updatedAt])
@@index([queryNormalized, updatedAt])
@@index([userId])
}
@@ -177,40 +155,3 @@ model SearchResult {
@@index([searchId, rank])
}
model Project {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind ProjectKind @default(folder)
title String
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
items ProjectItem[]
@@index([kind])
@@index([userId])
}
model ProjectItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String?
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
searchId String?
@@unique([projectId, chatId])
@@unique([projectId, searchId])
@@index([projectId, createdAt])
@@index([chatId])
@@index([searchId])
}
-59
View File
@@ -1,59 +0,0 @@
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);
};
}
}
-26
View File
@@ -1,26 +0,0 @@
export const CHROMIUM_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
export const FETCH_URL_ACCEPT =
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
return {
"User-Agent": CHROMIUM_USER_AGENT,
Accept: accept,
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
};
}
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
return {
...buildBrowserLikeRequestHeaders(accept),
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
};
}
+1 -104
View File
@@ -1,59 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config as loadDotenv } from "dotenv";
import { z } from "zod";
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", "brave"]).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)
);
}
import "dotenv/config";
const EnvSchema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
@@ -66,56 +12,7 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(),
GEMINI_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(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
// 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_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
ctx.addIssue({
code: "custom",
path: ["BRAVE_SEARCH_API_KEY"],
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
});
}
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>;
+1 -7
View File
@@ -5,11 +5,10 @@ import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible";
import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js";
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
import { warmModelCatalog } from "./llm/model-catalog.js";
import { registerRoutes } from "./routes.js";
const app = Fastify({
bodyLimit: 32 * 1024 * 1024,
disableRequestLogging: true,
logger: {
transport: {
@@ -21,7 +20,6 @@ const app = Fastify({
await ensureDatabaseReady(app.log);
await warmModelCatalog(app.log);
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, {
origin: true,
@@ -81,10 +79,6 @@ app.setErrorHandler((err, req, reply) => {
});
});
app.addHook("onClose", async () => {
stopModelCatalogRefreshLoop();
});
await registerRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST });
+243 -584
View File
@@ -1,33 +1,15 @@
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 { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js";
import { searchBrave } from "../search/brave.js";
import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import type { ChatMessage } from "./types.js";
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
const MAX_TOOL_ROUNDS = 4;
const DEFAULT_WEB_RESULTS = 5;
const MAX_WEB_RESULTS = 10;
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
const MAX_FETCH_MAX_CHARACTERS = 50_000;
const FETCH_TIMEOUT_MS = 12_000;
const 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;
export const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
const execFileAsync = promisify(execFile);
const WebSearchArgsSchema = z
.object({
@@ -39,8 +21,6 @@ const WebSearchArgsSchema = z
})
.strict();
type WebSearchArgs = z.infer<typeof WebSearchArgsSchema>;
const FetchUrlArgsSchema = z
.object({
url: z.string().trim().url(),
@@ -48,79 +28,7 @@ const FetchUrlArgsSchema = z
})
.strict();
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[] = [
const CHAT_TOOLS: any[] = [
{
type: "function",
function: {
@@ -182,72 +90,24 @@ const BASE_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] : []),
];
function getToolName(tool: any) {
return typeof tool?.function?.name === "string" ? tool.function.name : null;
}
export function getAvailableChatTools() {
return CHAT_TOOLS.map((tool) => {
const name = getToolName(tool);
if (!name) return null;
return {
name,
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
};
}).filter((tool): tool is { name: string; description: string } => tool !== null);
}
export function normalizeEnabledChatTools(value: unknown) {
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
return new Set(normalizeEnabledChatTools(params.enabledTools));
}
export function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const enabled = getEnabledToolSet(params);
return CHAT_TOOLS.filter((tool) => {
const name = getToolName(tool);
return name ? enabled.has(name) : false;
});
}
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.";
export type ToolRunOutcome = {
type ToolRunOutcome = {
ok: boolean;
[key: string]: unknown;
};
export type ToolAwareUsage = {
type ToolAwareUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
export type ToolAwareCompletionResult = {
type ToolAwareCompletionResult = {
text: string;
usage?: ToolAwareUsage;
raw: unknown;
@@ -259,12 +119,10 @@ export type ToolAwareStreamingEvent =
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "done"; result: ToolAwareCompletionResult };
export type ToolAwareCompletionParams = {
client: any;
type ToolAwareCompletionParams = {
client: OpenAI;
model: string;
messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number;
maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
@@ -275,17 +133,15 @@ export type ToolAwareCompletionParams = {
};
};
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
export type ToolExecutionEvent = {
toolCallId: string;
name: string;
status: ToolExecutionStatus;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt?: string;
durationMs?: number;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
@@ -313,13 +169,10 @@ function toSingleLine(value: string, maxLength = 220) {
);
}
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
if (name === "web_search") {
const query = typeof args.query === "string" ? args.query.trim() : "";
if (status === "initiated") {
return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web.";
}
if (status === "completed") {
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
}
@@ -328,42 +181,12 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: T
if (name === "fetch_url") {
const url = typeof args.url === "string" ? args.url.trim() : "";
if (status === "initiated") {
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
}
if (status === "completed") {
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
}
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
}
if (name === "codex_exec") {
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
if (status === "initiated") {
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
}
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 === "initiated") {
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
}
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 === "initiated") {
return `Running tool '${name}'.`;
}
if (status === "completed") {
return `Ran tool '${name}'.`;
}
@@ -422,25 +245,30 @@ function extractHtmlTitle(html: string) {
);
}
export function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const enabled = getEnabledToolSet(params);
return (
"You can use tools to gather up-to-date web information when needed. " +
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
(enabled.has("codex_exec")
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
: "") +
(enabled.has("shell_exec")
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
: "") +
"Do not fabricate tool outputs; reason only from provided tool results."
);
function normalizeIncomingMessages(messages: ChatMessage[]) {
const normalized = messages.map((m) => {
if (m.role === "tool") {
const name = m.name?.trim() || "tool";
return {
role: "user",
content: `Tool output (${name}):\n${m.content}`,
};
}
if (m.role === "assistant" || m.role === "system" || m.role === "user") {
const out: any = { role: m.role, content: m.content };
if (m.name && (m.role === "assistant" || m.role === "user")) {
out.name = m.name;
}
return out;
}
return { role: "user", content: m.content };
});
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
const exa = exaClient();
const response = await exa.search(args.query, {
type: args.type ?? "auto",
@@ -464,7 +292,6 @@ async function runExaWebSearchTool(args: WebSearchArgs): 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) => ({
@@ -482,62 +309,6 @@ async function runExaWebSearchTool(args: WebSearchArgs): 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 runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const response = await searchBrave(args.query, {
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
includeDomains: args.includeDomains,
excludeDomains: args.excludeDomains,
});
return {
ok: true,
searchEngine: "brave",
query: args.query,
requestId: response.requestId,
results: response.results.map((result, index) => ({
rank: index + 1,
...result,
})),
};
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
return runSearxngWebSearchTool(args);
}
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
return runBraveWebSearchTool(args);
}
return runExaWebSearchTool(args);
}
function assertSafeFetchUrl(urlRaw: string) {
const parsed = new URL(urlRaw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -559,7 +330,10 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
response = await fetch(parsed.toString(), {
redirect: "follow",
signal: controller.signal,
headers: buildBrowserLikeNavigationHeaders(),
headers: {
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
},
});
} finally {
clearTimeout(timeout);
@@ -605,232 +379,13 @@ 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}` };
}
export function parseToolArgs(raw: unknown) {
function parseToolArgs(raw: unknown) {
if (typeof raw !== "string") return {};
const trimmed = raw.trim();
if (!trimmed) return {};
@@ -841,50 +396,7 @@ export 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;
}
export 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)
);
}
export 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.",
});
}
export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
if (!usage) return false;
acc.inputTokens += usage.prompt_tokens ?? 0;
acc.outputTokens += usage.completion_tokens ?? 0;
@@ -892,19 +404,13 @@ export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
return true;
}
export function getUnstreamedText(finalText: string, streamedText: string) {
if (!finalText) return "";
if (!streamedText) return finalText;
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
}
export type NormalizedToolCall = {
type NormalizedToolCall = {
id: string;
name: string;
arguments: string;
};
export function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
return toolCalls.map((call: any, index: number) => ({
id: call?.id ?? `tool_call_${round}_${index}`,
name: call?.function?.name ?? "unknown_tool",
@@ -912,55 +418,17 @@ export function normalizeModelToolCalls(toolCalls: any[], round: number): Normal
}));
}
export type PreparedToolCallExecution = {
startedAtMs: number;
startedAt: string;
parsedArgs: Record<string, unknown>;
eventArgs: Record<string, unknown>;
parseError?: unknown;
};
export function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
let parsedArgs: Record<string, unknown> = {};
let parseError: unknown;
try {
parsedArgs = toRecord(parseToolArgs(call.arguments));
} catch (err) {
parseError = err;
}
const eventArgs = buildEventArgs(call.name, parsedArgs);
return {
event: {
toolCallId: call.id,
name: call.name,
status: "initiated",
summary: buildToolSummary(call.name, eventArgs, "initiated"),
args: eventArgs,
startedAt,
},
execution: {
startedAtMs,
startedAt,
parsedArgs,
eventArgs,
parseError,
},
};
}
export async function executeToolCallAndBuildEvent(
async function executeToolCallAndBuildEvent(
call: NormalizedToolCall,
execution: PreparedToolCallExecution,
params: ToolAwareCompletionParams
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
let toolResult: ToolRunOutcome;
let parsedArgs: Record<string, unknown> = {};
try {
if (execution.parseError) throw execution.parseError;
toolResult = await executeTool(call.name, execution.parsedArgs);
parsedArgs = toRecord(parseToolArgs(call.arguments));
toolResult = await executeTool(call.name, parsedArgs);
} catch (err: any) {
toolResult = {
ok: false,
@@ -981,11 +449,11 @@ export async function executeToolCallAndBuildEvent(
toolCallId: call.id,
name: call.name,
status,
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
args: execution.eventArgs,
startedAt: execution.startedAt,
summary: buildToolSummary(call.name, parsedArgs, status, error),
args: parsedArgs,
startedAt,
completedAt: new Date(completedAtMs).toISOString(),
durationMs: completedAtMs - execution.startedAtMs,
durationMs: completedAtMs - startedAtMs,
error,
resultPreview: buildResultPreview(toolResult),
};
@@ -996,3 +464,194 @@ export async function executeToolCallAndBuildEvent(
return { event, toolResult };
}
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tool_choice: "auto",
} as any);
rawResponses.push(completion);
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
const message = completion?.choices?.[0]?.message;
if (!message) {
return {
text: "",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
toolEvents,
};
}
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (!toolCalls.length) {
return {
text: typeof message.content === "string" ? message.content : "",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
};
}
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
totalToolCalls += normalizedToolCalls.length;
const assistantToolCallMessage: any = {
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
};
if (typeof message.content === "string" && message.content.length) {
assistantToolCallMessage.content = message.content;
}
conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
return {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
};
}
export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let totalToolCalls = 0;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const stream = await params.client.chat.completions.create({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: CHAT_TOOLS,
tool_choice: "auto",
stream: true,
stream_options: { include_usage: true },
} as any);
let roundText = "";
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
for await (const chunk of stream as any as AsyncIterable<any>) {
rawResponses.push(chunk);
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
const choice = chunk?.choices?.[0];
const deltaText = choice?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
roundText += deltaText;
if (roundToolCalls.size === 0) {
yield { type: "delta", text: deltaText };
}
}
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
for (const toolCall of deltaToolCalls) {
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
if (typeof toolCall?.id === "string" && toolCall.id.length) {
entry.id = toolCall.id;
}
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
entry.name = toolCall.function.name;
}
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
entry.arguments += toolCall.function.arguments;
}
roundToolCalls.set(idx, entry);
}
}
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
.sort((a, b) => a[0] - b[0])
.map(([_, call], index) => ({
id: call.id ?? `tool_call_${round}_${index}`,
name: call.name ?? "unknown_tool",
arguments: call.arguments || "{}",
}));
if (!normalizedToolCalls.length) {
yield {
type: "done",
result: {
text: roundText,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
});
for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
toolEvents.push(event);
yield { type: "tool_call", event };
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
yield {
type: "done",
result: {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
},
};
}
-114
View File
@@ -1,114 +0,0 @@
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
const DEFAULT_USER_LOCATION = "San Francisco, CA";
function currentDateString(now = new Date()) {
return now.toISOString().slice(0, 10);
}
function resolveUserLocation(userLocation?: string) {
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
}
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
}
function escapeAttribute(value: string) {
return value.replace(/"/g, "&quot;");
}
export function getImageAttachments(message: ChatMessage) {
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
}
export function getTextAttachments(message: ChatMessage) {
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
}
export 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(", ")}.`;
}
export 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");
}
export 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, ""),
};
}
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
return {
role: "system",
content: buildSystemPromptAugmentation(userLocation),
};
}
export function buildTopLevelSystemPrompt(messages: ChatMessage[], userLocation?: string, toolSystemPrompt?: string) {
return [toolSystemPrompt, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
.filter(Boolean)
.join("\n\n");
}
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;
}
+47 -48
View File
@@ -1,9 +1,5 @@
import type { FastifyBaseLogger } from "fastify";
import {
fetchProviderCatalogModels,
getProviderCatalogFallbackModels,
listModelCatalogProviders,
} from "./provider-adapters.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import type { Provider } from "./types.js";
export type ProviderModelSnapshot = {
@@ -12,14 +8,20 @@ export type ProviderModelSnapshot = {
error: string | null;
};
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
const providers: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000;
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const modelCatalog: ModelCatalogSnapshot = {};
const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null },
};
let catalogRefreshPromise: Promise<void> | null = null;
function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
let timeoutId: NodeJS.Timeout | null = null;
@@ -37,9 +39,24 @@ 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));
}
if (provider === "anthropic") {
const page = await anthropicClient().models.list({ limit: 200 });
return uniqSorted(page.data.map((model) => model.id));
}
const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
try {
const models = await withTimeout(fetchProviderCatalogModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
modelCatalog[provider] = {
models,
loadedAt: new Date().toISOString(),
@@ -48,53 +65,35 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) {
const message = err?.message ?? String(err);
const previous = modelCatalog[provider];
const fallbackModels = getProviderCatalogFallbackModels(provider);
modelCatalog[provider] = {
models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: previous?.loadedAt ?? null,
models: [],
loadedAt: new Date().toISOString(),
error: message,
};
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
}
}
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(listModelCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await refreshModelCatalog(logger);
}
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
const timer = setInterval(() => {
void refreshModelCatalog(logger);
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
};
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger)));
}
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
const snapshot: ModelCatalogSnapshot = {};
for (const provider of listModelCatalogProviders()) {
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
snapshot[provider] = {
models: [...entry.models],
loadedAt: entry.loadedAt,
error: entry.error,
};
}
return snapshot;
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,
},
};
}
+56 -21
View File
@@ -1,12 +1,12 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { buildToolLogMessageData } from "./chat-tools.js";
import { getProviderChatAdapter } from "./provider-adapters.js";
import { toPrismaProvider } from "./provider-ids.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareOpenAIChat } from "./chat-tools.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) {
return toPrismaProvider(p);
// Prisma enum values match these strings.
return p;
}
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
@@ -46,24 +46,59 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let usage: MultiplexResponse["usage"] | undefined;
let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
const adapter = getProviderChatAdapter(req.provider);
const r = await adapter.complete({
model: req.model,
messages: req.messages,
enabledTools: req.enabledTools,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
const r = await runToolAwareOpenAIChat({
client,
model: req.model,
chatId,
},
});
raw = r.raw;
outText = r.text;
usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
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 === "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 r = await client.messages.create({
model: req.model,
system,
max_tokens: req.maxTokens ?? 1024,
temperature: req.temperature,
messages: msgs as any,
});
raw = r;
outText = r.content
.map((c: any) => (c.type === "text" ? c.text : ""))
.join("")
.trim();
// Anthropic usage (SDK typing varies by version)
const ru: any = (r as any).usage;
if (ru) {
usage = {
inputTokens: ru.input_tokens,
outputTokens: ru.output_tokens,
totalTokens: (ru.input_tokens ?? 0) + (ru.output_tokens ?? 0),
};
}
} else {
throw new Error(`unknown provider: ${req.provider}`);
}
const latencyMs = Math.round(performance.now() - t0);
@@ -1,386 +0,0 @@
import {
appendDanglingToolIntentCorrection,
buildChatToolSystemPrompt,
executeToolCallAndBuildEvent,
getEnabledChatTools,
getUnstreamedText,
looksLikeDanglingToolIntent,
MAX_DANGLING_TOOL_INTENT_RETRIES,
MAX_TOOL_ROUNDS,
mergeUsage,
normalizeModelToolCalls,
prepareToolCallExecution,
type NormalizedToolCall,
type ToolAwareCompletionParams,
type ToolAwareCompletionResult,
type ToolAwareStreamingEvent,
type ToolExecutionEvent,
} from "../chat-tools.js";
import {
buildImageSummaryText,
buildSystemPromptAugmentationMessage,
buildTextAttachmentPrompt,
getImageAttachments,
getTextAttachments,
} from "../message-content.js";
import type { ChatMessage } from "../types.js";
function toContentParts(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 buildConversationMessage(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: toContentParts(message),
};
if (message.name && (message.role === "assistant" || message.role === "user")) {
out.name = message.name;
}
return out;
}
function normalizeMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildConversationMessage(message));
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
function normalizePlainMessages(messages: ChatMessage[], userLocation?: string) {
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildConversationMessage(message))];
}
function extractContent(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("");
}
export async function completeWithChatCompletionsApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params);
if (!enabledTools.length) {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainMessages(params.messages, params.userLocation),
temperature: params.temperature,
max_tokens: params.maxTokens,
} as any);
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const sawUsage = mergeUsage(usageAcc, completion?.usage);
const message = completion?.choices?.[0]?.message;
return {
text: extractContent(message),
usage: sawUsage ? usageAcc : undefined,
raw: { response: completion, api: "chat.completions" },
toolEvents: [],
};
}
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { 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({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: enabledTools,
tool_choice: "auto",
} as any);
rawResponses.push(completion);
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
const message = completion?.choices?.[0]?.message;
if (!message) {
return {
text: "",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
toolEvents,
};
}
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (!toolCalls.length) {
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,
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
};
}
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
totalToolCalls += normalizedToolCalls.length;
const assistantToolCallMessage: any = {
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
};
if (typeof message.content === "string" && message.content.length) {
assistantToolCallMessage.content = message.content;
}
conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) {
const { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
return {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
};
}
export async function* streamWithChatCompletionsApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params);
if (!enabledTools.length) {
const rawResponses: unknown[] = [];
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let text = "";
const stream = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainMessages(params.messages, params.userLocation),
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: [],
},
};
return;
}
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { 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({
model: params.model,
messages: conversation,
temperature: params.temperature,
max_tokens: params.maxTokens,
tools: enabledTools,
tool_choice: "auto",
stream: true,
stream_options: { include_usage: true },
} 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>) {
rawResponses.push(chunk);
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
const choice = chunk?.choices?.[0];
const deltaText = choice?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
roundText += deltaText;
if (!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: "" };
if (typeof toolCall?.id === "string" && toolCall.id.length) {
entry.id = toolCall.id;
}
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
entry.name = toolCall.function.name;
}
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
entry.arguments += toolCall.function.arguments;
}
roundToolCalls.set(idx, entry);
}
}
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
.sort((a, b) => a[0] - b[0])
.map(([_, call], index) => ({
id: call.id ?? `tool_call_${round}_${index}`,
name: call.name ?? "unknown_tool",
arguments: call.arguments || "{}",
}));
if (!normalizedToolCalls.length) {
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: {
text: roundText,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
const assistantToolCallMessage: any = {
role: "assistant",
tool_calls: normalizedToolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.arguments,
},
})),
};
if (roundText) {
assistantToolCallMessage.content = roundText;
}
conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) {
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
yield { type: "tool_call", event };
conversation.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(toolResult),
});
}
}
yield {
type: "done",
result: {
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
toolEvents,
},
};
}
-501
View File
@@ -1,501 +0,0 @@
import {
buildChatToolSystemPrompt,
executeToolCallAndBuildEvent,
getEnabledChatTools,
getUnstreamedText,
looksLikeDanglingToolIntent,
MAX_DANGLING_TOOL_INTENT_RETRIES,
MAX_TOOL_ROUNDS,
prepareToolCallExecution,
type NormalizedToolCall,
type ToolAwareCompletionParams,
type ToolAwareCompletionResult,
type ToolAwareStreamingEvent,
type ToolAwareUsage,
type ToolExecutionEvent,
} from "../chat-tools.js";
import {
buildImageSummaryText,
buildTextAttachmentPrompt,
buildTopLevelSystemPrompt,
getImageAttachments,
getTextAttachments,
parseImageDataUrl,
} from "../message-content.js";
import type { ChatMessage } from "../types.js";
type GeminiClient = {
apiKey: string;
baseURL: string;
};
const INTERNAL_CORRECTION =
"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 normalizeModelResourceName(model: string) {
const trimmed = model.trim().replace(/^\/+/, "");
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
}
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
url.searchParams.set("key", client.apiKey);
for (const [key, value] of Object.entries(extraParams)) {
url.searchParams.set(key, value);
}
return url;
}
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
const config: Record<string, unknown> = {};
if (params.temperature !== undefined) config.temperature = params.temperature;
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
return Object.keys(config).length ? config : undefined;
}
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
const input = schema as Record<string, unknown>;
const output: Record<string, unknown> = {};
if (typeof input.type === "string") output.type = input.type;
if (typeof input.description === "string") output.description = input.description;
if (typeof input.format === "string") output.format = input.format;
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
const items = toGeminiJsonSchema(input.items);
if (items) output.items = items;
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
const properties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input.properties)) {
const propertySchema = toGeminiJsonSchema(value);
if (propertySchema) properties[key] = propertySchema;
}
if (Object.keys(properties).length) output.properties = properties;
}
return Object.keys(output).length ? output : undefined;
}
function toGeminiTools(tools: any[]) {
const functionDeclarations = tools
.map((tool) => {
if (tool?.type !== "function") return null;
const declaration: Record<string, unknown> = {
name: tool.function.name,
description: tool.function.description,
};
const parameters = toGeminiJsonSchema(tool.function.parameters);
if (parameters) declaration.parameters = parameters;
return declaration;
})
.filter(Boolean);
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
}
function toContentParts(message: ChatMessage) {
const imageAttachments = getImageAttachments(message);
const textAttachments = getTextAttachments(message);
const parts: Array<Record<string, unknown>> = [];
for (const attachment of imageAttachments) {
const source = parseImageDataUrl(attachment);
parts.push({
inlineData: {
mimeType: source.mediaType,
data: source.data,
},
});
}
const imageSummary = buildImageSummaryText(imageAttachments);
if (imageSummary) {
parts.push({ text: imageSummary });
}
for (const attachment of textAttachments) {
parts.push({ text: buildTextAttachmentPrompt(attachment) });
}
if (message.content.trim()) {
parts.push({ text: message.content });
}
return parts.length ? parts : [{ text: "" }];
}
function buildConversationContent(message: ChatMessage) {
if (message.role === "system") {
throw new Error("System messages must be handled separately for Gemini.");
}
if (message.role === "tool") {
const name = message.name?.trim() || "tool";
return {
role: "user",
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
};
}
return {
role: message.role === "assistant" ? "model" : "user",
parts: toContentParts(message),
};
}
function buildBaseContents(messages: ChatMessage[]) {
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
}
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
return text ? { parts: [{ text }] } : undefined;
}
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
const normalized = normalizeUsage(usage);
if (!normalized) return false;
acc.inputTokens += normalized.inputTokens;
acc.outputTokens += normalized.outputTokens;
acc.totalTokens += normalized.totalTokens;
return true;
}
function normalizeUsage(usage: any) {
if (!usage) return null;
const inputTokens = usage.promptTokenCount ?? 0;
const outputTokens = usage.candidatesTokenCount ?? 0;
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
return { inputTokens, outputTokens, totalTokens };
}
function getCandidate(response: any) {
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
}
function getParts(response: any) {
const parts = getCandidate(response)?.content?.parts;
return Array.isArray(parts) ? parts : [];
}
function extractText(response: any) {
return getParts(response)
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
.join("");
}
function stringifyToolArgs(args: unknown) {
try {
return JSON.stringify(args ?? {});
} catch {
return "{}";
}
}
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
return parts
.filter((part) => part?.functionCall)
.map((part, index) => ({
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
name: part.functionCall.name ?? "unknown_tool",
arguments: stringifyToolArgs(part.functionCall.args),
}));
}
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
return {
functionResponse: {
id: call.id,
name: call.name,
response: toolResult,
},
};
}
function appendCorrection(conversation: any[], text: string) {
conversation.push({ role: "model", parts: [{ text }] });
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
}
async function parseGeminiResponse(response: Response) {
const bodyText = await response.text();
let body: any = null;
try {
body = bodyText ? JSON.parse(bodyText) : null;
} catch {
body = { raw: bodyText };
}
if (!response.ok) {
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
}
return body;
}
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return parseGeminiResponse(response);
}
function getFailureMessage(response: any, text: string, toolCallCount: number) {
const promptBlockReason = response?.promptFeedback?.blockReason;
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
const candidate = getCandidate(response);
const finishReason = candidate?.finishReason;
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
if (text || toolCallCount > 0) return null;
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
}
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
const tools = toGeminiTools(enabledTools);
return {
contents: conversation,
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
generationConfig: generationConfig(params),
tools,
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
};
}
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params);
const conversation = buildBaseContents(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 generateContent(params, buildRequest(params, conversation, enabledTools));
rawResponses.push(response);
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
const parts = getParts(response);
const text = extractText(response);
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
if (failureMessage) throw new Error(failureMessage);
if (!normalizedToolCalls.length) {
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
danglingToolIntentRetries += 1;
appendCorrection(conversation, text);
continue;
}
return {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
toolEvents,
};
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({ role: "model", parts });
const toolResultParts: any[] = [];
for (const call of normalizedToolCalls) {
const { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
}
conversation.push({ role: "user", parts: toolResultParts });
}
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: "gemini.generateContent" },
toolEvents,
};
}
function findSseBoundary(buffer: string) {
const crlf = buffer.indexOf("\r\n\r\n");
const lf = buffer.indexOf("\n\n");
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
if (lf === -1) return { index: crlf, length: 4 };
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
}
function parseSseEvent(rawEvent: string) {
const data = rawEvent
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n")
.trim();
if (!data || data === "[DONE]") return null;
return JSON.parse(data);
}
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
await parseGeminiResponse(response);
return;
}
if (!response.body) {
throw new Error("Gemini stream response did not include a body.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = findSseBoundary(buffer);
while (boundary) {
const rawEvent = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const event = parseSseEvent(rawEvent);
if (event) yield event;
boundary = findSseBoundary(buffer);
}
}
buffer += decoder.decode();
const tail = buffer.trim();
if (tail) {
const event = parseSseEvent(tail);
if (event) yield event;
}
}
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params);
const conversation = buildBaseContents(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;
if (!enabledTools.length) {
let text = "";
let latestUsage: any = null;
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
rawResponses.push(response);
if (response?.usageMetadata) latestUsage = response.usageMetadata;
const failureMessage = getFailureMessage(response, extractText(response), 0);
if (failureMessage) throw new Error(failureMessage);
const delta = extractText(response);
if (delta) {
text += delta;
yield { type: "delta", text: delta };
}
}
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
yield {
type: "done",
result: {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
toolEvents: [],
},
};
return;
}
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const roundParts: any[] = [];
let roundText = "";
let latestRoundResponse: any = null;
let latestRoundUsage: any = null;
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
rawResponses.push(response);
latestRoundResponse = response;
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
roundParts.push(...getParts(response));
roundText += extractText(response);
}
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
if (failureMessage) throw new Error(failureMessage);
if (!normalizedToolCalls.length) {
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
danglingToolIntentRetries += 1;
appendCorrection(conversation, roundText);
continue;
}
const unstreamedText = getUnstreamedText(roundText, "");
if (unstreamedText) {
yield { type: "delta", text: unstreamedText };
}
yield {
type: "done",
result: {
text: roundText,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({ role: "model", parts: roundParts });
const toolResultParts: any[] = [];
for (const call of normalizedToolCalls) {
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
yield { type: "tool_call", event };
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
}
conversation.push({ role: "user", parts: toolResultParts });
}
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: "gemini.streamGenerateContent",
},
toolEvents,
},
};
}
-511
View File
@@ -1,511 +0,0 @@
import {
buildChatToolSystemPrompt,
executeToolCallAndBuildEvent,
getEnabledChatTools,
looksLikeDanglingToolIntent,
MAX_DANGLING_TOOL_INTENT_RETRIES,
MAX_TOOL_ROUNDS,
parseToolArgs,
prepareToolCallExecution,
type NormalizedToolCall,
type ToolAwareCompletionParams,
type ToolAwareCompletionResult,
type ToolAwareStreamingEvent,
type ToolAwareUsage,
type ToolExecutionEvent,
type ToolRunOutcome,
} from "../chat-tools.js";
import {
buildImageSummaryText,
buildTextAttachmentPrompt,
buildTopLevelSystemPrompt,
getImageAttachments,
getTextAttachments,
parseImageDataUrl,
} from "../message-content.js";
import type { ChatMessage } from "../types.js";
const INTERNAL_CORRECTION =
"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.";
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
function readMaxTokens(value: unknown) {
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
}
function getModelInfoMaxTokens(modelInfo: any) {
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
}
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
if (params.maxTokens) return params.maxTokens;
const cached = modelMaxTokensCache.get(params.model);
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
try {
const retrieve = params.client?.models?.retrieve;
if (typeof retrieve === "function") {
const modelInfo = await retrieve.call(params.client.models, params.model);
const maxTokens = getModelInfoMaxTokens(modelInfo);
if (maxTokens) {
modelMaxTokensCache.set(params.model, {
maxTokens,
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
});
return maxTokens;
}
}
} catch {
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
}
return DEFAULT_ANTHROPIC_MAX_TOKENS;
}
function toTools(tools: any[]) {
return tools
.map((tool) => {
if (tool?.type !== "function") return null;
return {
name: tool.function.name,
description: tool.function.description,
input_schema: tool.function.parameters,
};
})
.filter(Boolean);
}
function toContentBlocks(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;
}
function buildConversationMessage(message: ChatMessage) {
if (message.role === "system") {
throw new Error("System messages must be handled separately for top-level-system protocols.");
}
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: toContentBlocks(message),
};
}
function buildBaseMessages(params: ToolAwareCompletionParams) {
return params.messages.filter((message) => message.role !== "system").map((message) => buildConversationMessage(message));
}
function stringifyToolInput(input: unknown) {
if (typeof input === "string") return input;
try {
return JSON.stringify(input ?? {});
} catch {
return "{}";
}
}
function normalizeToolCalls(content: any[], round: number): NormalizedToolCall[] {
return content
.filter((item) => item?.type === "tool_use")
.map((call: any, index: number) => ({
id: call?.id ?? `tool_call_${round}_${index}`,
name: call?.name ?? "unknown_tool",
arguments: stringifyToolInput(call?.input),
}));
}
function extractText(response: any) {
if (!Array.isArray(response?.content)) return "";
return response.content
.map((content: any) => (content?.type === "text" && typeof content.text === "string" ? content.text : ""))
.join("")
.trim();
}
function buildToolResultBlock(call: NormalizedToolCall, toolResult: ToolRunOutcome) {
return {
type: "tool_result",
tool_use_id: call.id,
content: JSON.stringify(toolResult),
is_error: !toolResult.ok,
};
}
function appendCorrection(conversation: any[], text: string) {
conversation.push({ role: "assistant", content: text });
conversation.push({
role: "user",
content: INTERNAL_CORRECTION,
});
}
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
if (!usage) return false;
const inputTokens = usage.input_tokens ?? 0;
const outputTokens = usage.output_tokens ?? 0;
acc.inputTokens += inputTokens;
acc.outputTokens += outputTokens;
acc.totalTokens += inputTokens + outputTokens;
return true;
}
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) {
const response = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: maxTokens,
temperature: params.temperature,
messages: buildBaseMessages(params),
} as any);
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const sawUsage = mergeUsage(usageAcc, response?.usage);
return {
text: extractText(response),
usage: sawUsage ? usageAcc : undefined,
raw: { response, api: "messages" },
toolEvents: [],
};
}
const conversation: any[] = buildBaseMessages(params);
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.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: maxTokens,
temperature: params.temperature,
messages: conversation,
tools: toTools(enabledTools),
tool_choice: { type: "auto" },
} as any);
rawResponses.push(response);
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
const content = Array.isArray(response?.content) ? response.content : [];
const normalizedToolCalls = normalizeToolCalls(content, round);
if (!normalizedToolCalls.length) {
const text = extractText(response);
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
danglingToolIntentRetries += 1;
appendCorrection(conversation, text);
continue;
}
return {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
toolEvents,
};
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({
role: "assistant",
content,
});
const toolResultBlocks: any[] = [];
for (const call of normalizedToolCalls) {
const { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
}
conversation.push({
role: "user",
content: toolResultBlocks,
});
}
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: "messages" },
toolEvents,
};
}
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) {
const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let roundInputTokens = 0;
let roundOutputTokens = 0;
let text = "";
const stream = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: maxTokens,
temperature: params.temperature,
messages: buildBaseMessages(params),
stream: true,
} as any);
for await (const ev of stream as any as AsyncIterable<any>) {
rawResponses.push(ev);
if (ev?.type === "message_start" && ev?.message?.usage) {
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
sawUsage = true;
}
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
const delta = ev.delta.text ?? "";
if (delta) {
text += delta;
yield { type: "delta", text: delta };
}
}
if (ev?.type === "message_delta" && ev.usage) {
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
sawUsage = true;
}
}
if (sawUsage) {
usageAcc.inputTokens += roundInputTokens;
usageAcc.outputTokens += roundOutputTokens;
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
}
yield {
type: "done",
result: {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "messages" },
toolEvents: [],
},
};
return;
}
const conversation: any[] = buildBaseMessages(params);
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.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: maxTokens,
temperature: params.temperature,
messages: conversation,
tools: toTools(enabledTools),
tool_choice: { type: "auto" },
stream: true,
} as any);
const contentByIndex = new Map<number, any>();
const toolArgumentByIndex = new Map<number, string>();
let roundText = "";
let roundHasToolCalls = false;
let roundInputTokens = 0;
let roundOutputTokens = 0;
let sawRoundUsage = false;
for await (const ev of stream as any as AsyncIterable<any>) {
rawResponses.push(ev);
if (ev?.type === "message_start" && ev?.message?.usage) {
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
sawRoundUsage = true;
}
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
const block = ev.content_block ?? {};
if (block.type === "tool_use") {
roundHasToolCalls = true;
contentByIndex.set(ev.index, {
type: "tool_use",
id: block.id,
name: block.name,
input: block.input ?? {},
});
toolArgumentByIndex.set(ev.index, "");
} else if (block.type === "text") {
contentByIndex.set(ev.index, {
type: "text",
text: typeof block.text === "string" ? block.text : "",
});
} else if (block.type) {
contentByIndex.set(ev.index, block);
}
}
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
if (ev.delta?.type === "text_delta") {
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
if (delta) {
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
if (block.type === "text") {
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
contentByIndex.set(ev.index, block);
}
roundText += delta;
}
} else if (ev.delta?.type === "input_json_delta") {
roundHasToolCalls = true;
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
}
}
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
const block = contentByIndex.get(ev.index);
if (block?.type === "tool_use") {
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
try {
block.input = parseToolArgs(rawArguments);
} catch {
block.input = {};
}
contentByIndex.set(ev.index, block);
}
}
if (ev?.type === "message_delta" && ev.usage) {
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
sawRoundUsage = true;
}
}
if (sawRoundUsage) {
usageAcc.inputTokens += roundInputTokens;
usageAcc.outputTokens += roundOutputTokens;
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
sawUsage = true;
}
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
const assistantContent = indexedContent.map(([, block]) => block);
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
.filter(([, block]) => block?.type === "tool_use")
.map(([index, block], callIndex) => ({
id: block.id ?? `tool_call_${round}_${callIndex}`,
name: block.name ?? "unknown_tool",
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
}));
if (!normalizedToolCalls.length) {
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
danglingToolIntentRetries += 1;
appendCorrection(conversation, roundText);
continue;
}
if (roundText) {
yield { type: "delta", text: roundText };
}
yield {
type: "done",
result: {
text: roundText,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
toolEvents,
},
};
return;
}
totalToolCalls += normalizedToolCalls.length;
conversation.push({
role: "assistant",
content: assistantContent,
});
const toolResultBlocks: any[] = [];
for (const call of normalizedToolCalls) {
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event);
yield { type: "tool_call", event };
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
}
conversation.push({
role: "user",
content: toolResultBlocks,
});
}
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: "messages" },
toolEvents,
},
};
}
-332
View File
@@ -1,332 +0,0 @@
import {
appendDanglingToolIntentCorrection,
buildChatToolSystemPrompt,
executeToolCallAndBuildEvent,
getEnabledChatTools,
getUnstreamedText,
looksLikeDanglingToolIntent,
MAX_DANGLING_TOOL_INTENT_RETRIES,
MAX_TOOL_ROUNDS,
prepareToolCallExecution,
type NormalizedToolCall,
type ToolAwareCompletionParams,
type ToolAwareCompletionResult,
type ToolAwareStreamingEvent,
type ToolAwareUsage,
type ToolExecutionEvent,
} from "../chat-tools.js";
import {
buildImageSummaryText,
buildSystemPromptAugmentationMessage,
buildTextAttachmentPrompt,
getImageAttachments,
getTextAttachments,
} from "../message-content.js";
import type { ChatMessage } from "../types.js";
function toResponsesTools(tools: any[]) {
return 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,
};
});
}
function toContentParts(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 buildInputMessage(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: toContentParts(message),
};
}
function normalizeInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildInputMessage(message));
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
function mergeUsage(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 getOutputItems(response: any) {
return Array.isArray(response?.output) ? response.output : [];
}
function extractText(response: any, fallback = "") {
if (typeof response?.output_text === "string") return response.output_text;
const parts: string[] = [];
for (const item of getOutputItems(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 getFailureMessage(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 normalizeToolCalls(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 ?? "{}",
}));
}
export async function completeWithResponsesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
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: toResponsesTools(enabledTools),
tool_choice: "auto",
parallel_tool_calls: true,
store: true,
} as any);
rawResponses.push(response);
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
const failureMessage = getFailureMessage(response);
if (failureMessage) {
throw new Error(failureMessage);
}
const outputItems = getOutputItems(response);
const normalizedToolCalls = normalizeToolCalls(outputItems, round);
if (!normalizedToolCalls.length) {
const text = extractText(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 { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, 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* streamWithResponsesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
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: toResponsesTools(enabledTools),
tool_choice: "auto",
parallel_tool_calls: true,
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 = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
completedResponse = event.response;
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
} else if (event?.type === "error") {
throw new Error(event.message ?? "Responses stream failed.");
}
}
const failureMessage = getFailureMessage(completedResponse);
if (failureMessage) {
throw new Error(failureMessage);
}
const outputItems = getOutputItems(completedResponse);
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
const normalizedToolCalls = normalizeToolCalls(responseOutputItems, round);
if (!normalizedToolCalls.length) {
const text = extractText(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: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, 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,
},
};
}
-284
View File
@@ -1,284 +0,0 @@
import {
normalizeEnabledChatTools,
type ToolAwareCompletionParams,
type ToolAwareCompletionResult,
type ToolAwareStreamingEvent,
} from "./chat-tools.js";
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js";
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
import { env } from "../env.js";
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
import type { ChatMessage, Provider } from "./types.js";
type ProviderAdapterParams = {
model: string;
messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number;
maxTokens?: number;
logContext?: ToolAwareCompletionParams["logContext"];
};
export type ProviderChatAdapter = {
provider: Provider;
complete(params: ProviderAdapterParams): Promise<ToolAwareCompletionResult>;
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
};
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
type ChatProtocol = {
id: ChatProtocolId;
complete(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult>;
stream(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent>;
};
type ModelCatalogSpec = {
enabled?: () => boolean;
fetchModels(client: any): Promise<string[]>;
fallbackModels?: () => string[];
sortModels?: (models: string[]) => string[];
};
type ProviderBackendSpec = {
createClient: () => any;
plainProtocol: ChatProtocol;
toolProtocol?: ChatProtocol;
managedTools?: boolean;
modelCatalog?: ModelCatalogSpec;
};
const chatCompletionsProtocol: ChatProtocol = {
id: "chat-completions",
complete: completeWithChatCompletionsApi,
stream: streamWithChatCompletionsApi,
};
const messagesProtocol: ChatProtocol = {
id: "messages",
complete: completeWithMessagesApi,
stream: streamWithMessagesApi,
};
const geminiProtocol: ChatProtocol = {
id: "gemini",
complete: completeWithGeminiApi,
stream: streamWithGeminiApi,
};
const responsesProtocol: ChatProtocol = {
id: "responses",
complete: completeWithResponsesApi,
stream: streamWithResponsesApi,
};
function uniqSorted(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
function modelIdsFromListResponse(page: any) {
return Array.isArray(page?.data)
? page.data.map((model: any) => model?.id).filter((id: unknown): id is string => typeof id === "string")
: [];
}
function stripModelResourcePrefix(model: string) {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}
function isLikelyResponsesApiModel(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);
}
function isLikelyGeminiChatModel(model: string) {
const id = model.toLowerCase();
if (!id.startsWith("gemini-")) return false;
if (id.includes("embedding") || id.includes("embed")) return false;
if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false;
if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false;
if (id.includes("computer-use") || id.includes("robotics")) return false;
return true;
}
function preferGeminiModels(models: string[]) {
const preferred = [
"gemini-3.5-flash",
"gemini-flash-latest",
"gemini-3.1-flash-lite",
"gemini-3-flash-preview",
"gemini-pro-latest",
];
const modelSet = new Set(models);
return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))];
}
async function fetchJson(url: URL): Promise<any> {
const response = await fetch(url);
const body: any = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`);
}
return body;
}
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
return {
client,
model: params.model,
messages: params.messages,
enabledTools,
userLocation: params.userLocation,
temperature: params.temperature,
maxTokens: params.maxTokens,
logContext: params.logContext,
};
}
function selectChatProtocol(spec: ProviderBackendSpec, params: Pick<ProviderAdapterParams, "enabledTools">) {
const enabledTools = normalizeEnabledChatTools(params.enabledTools);
const useManagedTools = spec.managedTools === true && spec.toolProtocol && enabledTools.length > 0;
return {
protocol: useManagedTools ? spec.toolProtocol! : spec.plainProtocol,
enabledTools: useManagedTools ? enabledTools : [],
managedTools: Boolean(useManagedTools),
};
}
function createProviderChatAdapter(provider: Provider, spec: ProviderBackendSpec): ProviderChatAdapter {
return {
provider,
complete(params) {
const selected = selectChatProtocol(spec, params);
return selected.protocol.complete(withClient(params, spec.createClient(), selected.enabledTools));
},
stream(params) {
const selected = selectChatProtocol(spec, params);
return selected.protocol.stream(withClient(params, spec.createClient(), selected.enabledTools));
},
};
}
const backendSpecs: Record<Provider, ProviderBackendSpec> = {
openai: {
createClient: openaiClient,
plainProtocol: chatCompletionsProtocol,
toolProtocol: responsesProtocol,
managedTools: true,
modelCatalog: {
async fetchModels(client) {
const page = await client.models.list();
return modelIdsFromListResponse(page).filter(isLikelyResponsesApiModel);
},
},
},
anthropic: {
createClient: anthropicClient,
plainProtocol: messagesProtocol,
toolProtocol: messagesProtocol,
managedTools: true,
modelCatalog: {
async fetchModels(client) {
const page = await client.models.list({ limit: 200 });
return modelIdsFromListResponse(page);
},
},
},
xai: {
createClient: xaiClient,
plainProtocol: chatCompletionsProtocol,
toolProtocol: chatCompletionsProtocol,
managedTools: true,
modelCatalog: {
async fetchModels(client) {
const page = await client.models.list();
return modelIdsFromListResponse(page);
},
},
},
gemini: {
createClient: geminiClient,
plainProtocol: geminiProtocol,
toolProtocol: geminiProtocol,
managedTools: true,
modelCatalog: {
async fetchModels(client) {
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
url.searchParams.set("key", client.apiKey);
url.searchParams.set("pageSize", "1000");
const page = await fetchJson(url);
return Array.isArray(page?.models)
? page.models
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
.map((model: any) => model?.name)
.filter((id: unknown): id is string => typeof id === "string")
.map(stripModelResourcePrefix)
.filter(isLikelyGeminiChatModel)
: [];
},
sortModels: preferGeminiModels,
},
},
"hermes-agent": {
createClient: hermesAgentClient,
plainProtocol: chatCompletionsProtocol,
managedTools: false,
modelCatalog: {
enabled: isHermesAgentConfigured,
async fetchModels(client) {
const page = await client.models.list();
const models = modelIdsFromListResponse(page);
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
return models;
},
fallbackModels() {
return env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
},
},
},
};
const providerChatAdapters: Record<Provider, ProviderChatAdapter> = Object.fromEntries(
Object.entries(backendSpecs).map(([provider, spec]) => [provider, createProviderChatAdapter(provider as Provider, spec)])
) as Record<Provider, ProviderChatAdapter>;
export function getProviderChatAdapter(provider: Provider) {
return providerChatAdapters[provider];
}
export function describeProviderChatBackend(provider: Provider, enabledTools?: string[]) {
const selected = selectChatProtocol(backendSpecs[provider], { enabledTools });
return {
provider,
protocol: selected.protocol.id,
managedTools: selected.managedTools,
enabledTools: selected.enabledTools,
};
}
export function listModelCatalogProviders(): Provider[] {
return (Object.entries(backendSpecs) as [Provider, ProviderBackendSpec][])
.filter(([, spec]) => {
const catalog = spec.modelCatalog;
return catalog !== undefined && catalog.enabled?.() !== false;
})
.map(([provider]) => provider);
}
export async function fetchProviderCatalogModels(provider: Provider) {
const spec = backendSpecs[provider].modelCatalog;
if (!spec) return [];
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
return spec.sortModels ? spec.sortModels(models) : models;
}
export function getProviderCatalogFallbackModels(provider: Provider) {
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
}
-46
View File
@@ -1,46 +0,0 @@
import type { Provider } from "./types.js";
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
const apiToPrismaProvider = {
openai: "openai",
anthropic: "anthropic",
xai: "xai",
gemini: "gemini",
"hermes-agent": "hermes_agent",
} as const satisfies Record<Provider, PrismaProvider>;
const prismaToApiProvider = {
openai: "openai",
anthropic: "anthropic",
xai: "xai",
gemini: "gemini",
hermes_agent: "hermes-agent",
"hermes-agent": "hermes-agent",
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
export function toPrismaProvider(provider: Provider): PrismaProvider {
return apiToPrismaProvider[provider];
}
export function fromPrismaProvider(provider: unknown): Provider | null {
if (provider === null || provider === undefined) return null;
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? 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;
}
+1 -21
View File
@@ -1,5 +1,5 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import { env } from "../env.js";
export function openaiClient() {
@@ -13,26 +13,6 @@ export function xaiClient() {
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
}
export function geminiClient() {
if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set");
return {
apiKey: env.GEMINI_API_KEY,
baseURL: "https://generativelanguage.googleapis.com/v1beta",
};
}
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 });
+131 -116
View File
@@ -1,24 +1,14 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import {
buildToolLogMessageData,
type ToolExecutionEvent,
} from "./chat-tools.js";
import { getProviderChatAdapter } from "./provider-adapters.js";
import { toPrismaProvider } from "./provider-ids.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareOpenAIChatStream, type ToolExecutionEvent } from "./chat-tools.js";
import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
export type StreamEvent =
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "delta"; text: string }
| { type: "done"; text: string; usage?: StreamUsage }
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
| { type: "error"; message: string };
function getChatIdOrCreate(chatId?: string) {
@@ -28,129 +18,154 @@ function getChatIdOrCreate(chatId?: string) {
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
const t0 = performance.now();
const shouldPersist = req.persist !== false;
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
const chatId = await getChatIdOrCreate(req.chatId);
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;
const call = await prisma.llmCall.create({
data: {
chatId,
provider: req.provider as any,
model: req.model,
request: req as any,
},
select: { id: true },
});
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,
},
}),
]);
}
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedModel: req.model,
},
}),
]);
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
let text = "";
let usage: StreamUsage | undefined;
let usage: StreamEvent extends any ? any : never;
let raw: unknown = { streamed: true };
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
try {
const adapter = getProviderChatAdapter(req.provider);
const streamEvents = adapter.stream({
model: req.model,
messages: req.messages,
enabledTools: req.enabledTools,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
for await (const ev of runToolAwareOpenAIChatStream({
client,
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 };
continue;
}
if (ev.type === "tool_call") {
if (ev.event.status !== "initiated" && 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,
},
});
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
})) {
if (ev.type === "delta") {
text += ev.text;
yield { type: "delta", text: ev.text };
continue;
}
yield { type: "tool_call", event: ev.event };
continue;
}
raw = ev.result.raw;
usage = ev.result.usage;
text = ev.result.text;
if (ev.type === "tool_call") {
toolMessages.push(buildToolLogMessageData(chatId, ev.event));
yield { type: "tool_call", event: ev.event };
continue;
}
raw = ev.result.raw;
usage = ev.result.usage;
text = ev.result.text;
}
} else if (req.provider === "anthropic") {
const client = anthropicClient();
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 stream = await client.messages.create({
model: req.model,
system,
max_tokens: req.maxTokens ?? 1024,
temperature: req.temperature,
messages: msgs as any,
stream: true,
});
for await (const ev of stream as any as AsyncIterable<any>) {
// Anthropic streaming events include content_block_delta with text_delta
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
const delta = ev.delta.text ?? "";
if (delta) {
text += delta;
yield { type: "delta", text: delta };
}
}
// capture usage if present on message_delta
if (ev?.type === "message_delta" && ev?.usage) {
usage = {
inputTokens: ev.usage.input_tokens,
outputTokens: ev.usage.output_tokens,
totalTokens:
(ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
};
}
// some streams end with message_stop
}
raw = { streamed: true, provider: "anthropic" };
} else {
throw new Error(`unknown provider: ${req.provider}`);
}
const latencyMs = Math.round(performance.now() - t0);
if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: {
chatId,
role: "assistant" as any,
content: text,
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
},
});
await tx.llmCall.update({
where: { id: call.id },
data: {
response: raw as any,
latencyMs,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
await prisma.$transaction(async (tx) => {
if (toolMessages.length) {
await tx.message.createMany({
data: toolMessages.map((message) => ({
chatId: message.chatId,
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.metadata as any,
})),
});
}
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
});
}
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);
if (shouldPersist && call) {
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
}
await prisma.llmCall.update({
where: { id: call.id },
data: {
error: e?.message ?? String(e),
latencyMs,
},
});
yield { type: "error", message: e?.message ?? String(e) };
}
}
+1 -30
View File
@@ -1,45 +1,16 @@
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "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 Provider = "openai" | "anthropic" | "xai";
export type ChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
export type MultiplexRequest = {
chatId?: string;
persist?: boolean;
clientRequestId?: string;
provider: Provider;
model: string;
messages: ChatMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number;
maxTokens?: number;
};
+299 -1160
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export function normalizeSearchQuery(value: string | null | undefined) {
const normalized = value?.trim().toLowerCase() ?? "";
return normalized || null;
}
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
}
export function isFreshSearchCacheHit(
candidate: {
updatedAt: Date | string;
resultCount: number;
answerText?: string | null;
isActive?: boolean;
},
now = new Date(),
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
) {
if (candidate.isActive) return false;
if (!hasReusableSearchPayload(candidate)) return false;
const updatedAtMs = new Date(candidate.updatedAt).getTime();
if (!Number.isFinite(updatedAtMs)) return false;
return now.getTime() - updatedAtMs <= ttlMs;
}
-305
View File
@@ -1,305 +0,0 @@
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js";
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
const RATE_LIMIT_RESET_SAFETY_MS = 50;
const MAX_RATE_LIMIT_RETRIES = 3;
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
type RateLimitPolicy = {
limit: number;
windowSeconds: number;
};
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
let lastRequestAtMs = 0;
let nextRequestAtMs = 0;
let quotaUnavailableUntilMs = 0;
let requestQueue = Promise.resolve();
export type BraveSearchOptions = {
numResults: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type BraveSearchResult = {
title: string | null;
url: string | null;
publishedDate: string | null;
author: string | null;
summary: string | null;
text: string | null;
highlights: string[];
};
export type BraveSearchResponse = {
query: string;
requestId: string | null;
results: BraveSearchResult[];
};
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 requireBraveSearchApiKey() {
if (!env.BRAVE_SEARCH_API_KEY) {
throw new Error("BRAVE_SEARCH_API_KEY not set");
}
return env.BRAVE_SEARCH_API_KEY;
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
function addIntervalSafety(intervalMs: number) {
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
}
function parseCommaSeparatedNumbers(value: string | null) {
if (!value) return [];
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
}
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
if (!value) return [];
return value.split(",").flatMap((part) => {
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
if (!match) return [];
const limit = Number(match[1]);
const windowSeconds = Number(match[2]);
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
});
}
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
if (!policies.length) return null;
let burstIndex = 0;
for (let index = 1; index < policies.length; index += 1) {
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
}
return burstIndex;
}
function updateRateLimitState(headers: Headers) {
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
const burstIndex = getBurstPolicyIndex(policies);
if (burstIndex === null) return;
const burstPolicy = policies[burstIndex]!;
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
}
requestIntervalMs = learnedIntervalMs;
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
for (let index = 0; index < policies.length; index += 1) {
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
if (index === burstIndex) {
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
} else {
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
}
}
}
function assertLongTermQuotaAvailable() {
if (quotaUnavailableUntilMs <= Date.now()) {
quotaUnavailableUntilMs = 0;
return;
}
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
}
async function waitForRateLimitSlot() {
const reservation = requestQueue.then(async () => {
while (true) {
assertLongTermQuotaAvailable();
const waitMs = nextRequestAtMs - Date.now();
if (waitMs <= 0) break;
await sleep(waitMs);
}
lastRequestAtMs = Date.now();
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
});
requestQueue = reservation.catch(() => undefined);
await reservation;
}
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
const exponentialDelayMs = 2 ** retryNumber * 1_000;
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
}
async function fetchBrave(url: URL) {
const apiKey = requireBraveSearchApiKey();
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
await waitForRateLimitSlot();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
signal: controller.signal,
headers: {
...buildBrowserLikeRequestHeaders("application/json"),
"X-Subscription-Token": apiKey,
},
});
} finally {
clearTimeout(timeout);
}
updateRateLimitState(response.headers);
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
await response.arrayBuffer();
if (retryDelayMs === null) {
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
}
await sleep(retryDelayMs);
}
throw new Error("Brave Search API request failed after rate-limit retries.");
}
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: BraveSearchResult[], options: BraveSearchOptions) {
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 buildBraveQuery(query: string, options: BraveSearchOptions) {
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: BraveSearchOptions) {
const url = new URL(BRAVE_WEB_SEARCH_URL);
url.searchParams.set("q", buildBraveQuery(query, options));
url.searchParams.set("count", String(options.numResults));
url.searchParams.set("safesearch", "moderate");
url.searchParams.set("result_filter", "web");
url.searchParams.set("text_decorations", "false");
url.searchParams.set("extra_snippets", "true");
return url;
}
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 mapWebResult(result: any): BraveSearchResult {
const description = stringOrNull(result?.description);
const extraSnippets = stringArray(result?.extra_snippets);
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
const combinedText = snippets.join("\n\n");
return {
title: stringOrNull(result?.title),
url: stringOrNull(result?.url),
publishedDate: stringOrNull(result?.page_age),
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
summary: description ? clipText(description, 1_400) : null,
text: combinedText ? clipText(combinedText, 700) : null,
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
};
}
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
const url = buildSearchUrl(query, options);
const response = await fetchBrave(url);
if (!response.ok) {
await response.arrayBuffer();
throw new Error(`Brave Search API request failed with status ${response.status}.`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
await response.arrayBuffer();
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
}
const data: any = await response.json();
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
return {
query,
requestId: response.headers.get("x-request-id"),
results: filterResultsByDomains(results, options).slice(0, options.numResults),
};
}
export function resetBraveRateLimitStateForTests() {
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
lastRequestAtMs = 0;
nextRequestAtMs = 0;
quotaUnavailableUntilMs = 0;
requestQueue = Promise.resolve();
}
-158
View File
@@ -1,158 +0,0 @@
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
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: buildBrowserLikeRequestHeaders(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) };
}
-34
View File
@@ -1,34 +0,0 @@
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" } },
]);
});
-284
View File
@@ -1,284 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { env } from "../src/env.js";
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response(
JSON.stringify({
web: {
results: [
{
title: " Brave result ",
url: "https://docs.example.com/article",
description: "Main\n snippet",
extra_snippets: ["Extra snippet one", "Extra snippet two"],
page_age: "2026-07-18T12:00:00Z",
profile: { name: "Example Docs" },
},
{
title: "Excluded result",
url: "https://blocked.example.com/article",
description: "Should be filtered",
},
],
},
}),
{
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-request-id": "brave-request-1",
},
}
);
}) as typeof fetch;
try {
const response = await searchBrave("latest docs", {
numResults: 5,
includeDomains: ["https://example.com/path"],
excludeDomains: ["blocked.example.com"],
});
assert.equal(fetchCalls.length, 1);
const requestUrl = new URL(String(fetchCalls[0]?.input));
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
assert.equal(requestUrl.searchParams.get("count"), "5");
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
assert.deepEqual(response, {
query: "latest docs",
requestId: "brave-request-1",
results: [
{
title: "Brave result",
url: "https://docs.example.com/article",
publishedDate: "2026-07-18T12:00:00Z",
author: "Example Docs",
summary: "Main snippet",
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
},
],
});
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave rejects requests without an API key", async () => {
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = undefined;
try {
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
} finally {
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave reports non-JSON responses", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () =>
new Response("upstream error", {
status: 200,
headers: { "content-type": "text/plain" },
})) as typeof fetch;
try {
await assert.rejects(
() => searchBrave("test", { numResults: 1 }),
/Brave Search API returned text\/plain/
);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const requestStartedAt: number[] = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
requestStartedAt.push(Date.now());
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "1, 1999",
"x-ratelimit-reset": "1, 2678400",
},
});
}) as typeof fetch;
try {
await Promise.all([
searchBrave("burst one", { numResults: 1 }),
searchBrave("burst two", { numResults: 1 }),
searchBrave("burst three", { numResults: 1 }),
]);
assert.equal(requestStartedAt.length, 3);
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const requestStartedAt: number[] = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
requestStartedAt.push(Date.now());
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
"x-ratelimit-remaining": "49, 0",
"x-ratelimit-reset": "1, 2678400",
},
});
}) as typeof fetch;
try {
await searchBrave("learn upgraded policy", { numResults: 1 });
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
assert.equal(requestStartedAt.length, 9);
const burstStartedAt = requestStartedAt.slice(1);
for (let index = 1; index < burstStartedAt.length; index += 1) {
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
}
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave retries 429 responses after the burst window resets", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
let fetchCount = 0;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
fetchCount += 1;
const rateLimitHeaders = {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
"x-ratelimit-reset": "1, 2678400",
};
if (fetchCount === 1) {
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
status: 429,
headers: rateLimitHeaders,
});
}
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
}) as typeof fetch;
try {
const startedAt = Date.now();
await searchBrave("retry burst", { numResults: 1 });
assert.equal(fetchCount, 2);
assert.ok(Date.now() - startedAt >= 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave does not wait for exhausted long-term quotas", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
status: 429,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "0, 0",
"x-ratelimit-reset": "1, 100000",
},
})) as typeof fetch;
try {
const startedAt = Date.now();
await assert.rejects(
() => searchBrave("quota exhausted", { numResults: 1 }),
/rate limit quota is exhausted beyond the retry window/
);
assert.ok(Date.now() - startedAt < 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
let fetchCount = 0;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "0, 0",
"x-ratelimit-reset": "1, 100000",
},
});
}) as typeof fetch;
try {
await searchBrave("last allowed query", { numResults: 1 });
await assert.rejects(
() => searchBrave("over quota query", { numResults: 1 }),
/long-term quota is exhausted/
);
assert.equal(fetchCount, 1);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
-252
View File
@@ -1,252 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
cwd: serverRoot,
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
stdio: "pipe",
});
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
import("../src/routes.js"),
import("../src/db.js"),
]);
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
test.after(async () => {
await app.close();
await prisma.$disconnect();
rmSync(databaseDir, { recursive: true, force: true });
});
test("forks copy bounded history and keep every child grouped under the root", async () => {
const timestamp = new Date("2026-08-16T12:00:00.000Z");
const source = await prisma.chat.create({
data: {
title: "Planning session",
initiatedProvider: "openai",
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: "anthropic",
lastUsedModel: "claude-sonnet-4-20250514",
additionalSystemPrompt: "Keep answers concise.",
enabledTools: ["web_search"],
messages: {
create: [
{
id: "source-message-0001",
createdAt: timestamp,
role: "user",
content: "Plan a trip",
metadata: {
clientRequestId: "source-request",
attachments: [{ kind: "text", filename: "notes.txt" }],
},
},
{
id: "source-message-0002",
createdAt: timestamp,
role: "assistant",
content: "First assistant response",
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
},
{
id: "source-message-0003",
createdAt: timestamp,
role: "user",
content: "Only visible in a whole-chat fork",
},
],
},
calls: {
create: {
provider: "openai",
model: "gpt-4.1-mini",
request: { input: "Plan a trip" },
},
},
},
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
});
const forkResponse = await app.inject({
method: "POST",
url: `/v1/chats/${source.id}/fork`,
payload: { messageId: source.messages[1].id },
});
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
const forkSummary = forkResponse.json().chat;
assert.equal(forkSummary.parentChatId, source.id);
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
assert.equal(forkSummary.titleGenerationPending, true);
assert.equal(forkSummary.starred, false);
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
const fork = detailResponse.json().chat;
assert.deepEqual(
fork.messages.map((message: any) => [message.role, message.content]),
[
["user", "Plan a trip"],
["assistant", "First assistant response"],
]
);
assert.notEqual(fork.messages[0].id, source.messages[0].id);
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
assert.deepEqual(fork.messages[0].metadata, {
attachments: [{ kind: "text", filename: "notes.txt" }],
});
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
assert.equal(fork.initiatedProvider, "openai");
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
assert.equal(fork.lastUsedProvider, "anthropic");
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
assert.deepEqual(fork.enabledTools, ["web_search"]);
assert.deepEqual(fork.calls, []);
const childForkResponse = await app.inject({
method: "POST",
url: `/v1/chats/${forkSummary.id}/fork`,
payload: {},
});
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
const childFork = childForkResponse.json().chat;
assert.equal(childFork.parentChatId, source.id);
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
assert.equal(childFork.titleGenerationPending, true);
const wholeForkResponse = await app.inject({
method: "POST",
url: `/v1/chats/${source.id}/fork`,
payload: {},
});
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
const wholeFork = wholeForkResponse.json().chat;
assert.equal(wholeFork.parentChatId, source.id);
assert.equal(wholeFork.title, "Fork of Planning session");
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
assert.deepEqual(
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
);
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
const suggestedTitleResponse = await app.inject({
method: "POST",
url: "/v1/chats/title/suggest",
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
});
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
const repeatedTitleResponse = await app.inject({
method: "POST",
url: "/v1/chats/title/suggest",
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
});
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
where: { id: source.id },
include: { messages: true, calls: true },
});
assert.equal(sourceAfterForks.messages.length, 3);
assert.equal(sourceAfterForks.calls.length, 1);
const manualTitleResponse = await app.inject({
method: "PATCH",
url: `/v1/chats/${forkSummary.id}`,
payload: { title: "Manual branch title" },
});
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
const activeForkStream = registerActiveChatStream(wholeFork.id);
try {
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
} finally {
clearActiveChatStream(wholeFork.id, activeForkStream);
}
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
const unrelated = await prisma.chat.create({
data: {
messages: { create: { role: "assistant", content: "Unrelated response" } },
},
include: { messages: true },
});
const countBeforeInvalidForks = await prisma.chat.count();
const wrongChatResponse = await app.inject({
method: "POST",
url: `/v1/chats/${source.id}/fork`,
payload: { messageId: unrelated.messages[0].id },
});
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
assert.equal(wrongChatResponse.json().message, "message not found in chat");
const nonAssistantResponse = await app.inject({
method: "POST",
url: `/v1/chats/${source.id}/fork`,
payload: { messageId: source.messages[0].id },
});
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
const concurrentDeleteResponses = await Promise.all([
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
]);
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
const concurrentChildResponse = await app.inject({
method: "POST",
url: `/v1/chats/${concurrentRoot.id}/fork`,
payload: {},
});
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
const concurrentChild = concurrentChildResponse.json().chat;
const concurrentFamilyDeleteResponses = await Promise.all([
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
]);
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
});
-561
View File
@@ -1,561 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.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("Responses API 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(
streamWithResponsesApi({
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("Chat Completions API 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(
streamWithChatCompletionsApi({
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(
streamWithChatCompletionsApi({
client: client as any,
model: "hermes-agent",
messages: [{ role: "user", content: "Say hi" }],
enabledTools: [],
})
);
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");
});
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
let requestBody: any = null;
let retrievedModel: string | null = null;
const client = {
models: {
retrieve: async (model: string) => {
retrievedModel = model;
return { id: model, max_tokens: 128000 };
},
},
messages: {
create: async (body: any) => {
requestBody = body;
return {
content: [{ type: "text", text: "Done" }],
usage: { input_tokens: 1, output_tokens: 1 },
};
},
},
};
const result = await completeWithMessagesApi({
client: client as any,
model: "claude-max-default-test",
messages: [{ role: "user", content: "Say done" }],
});
assert.equal(retrievedModel, "claude-max-default-test");
assert.equal(requestBody?.max_tokens, 128000);
assert.equal(result.text, "Done");
});
test("Messages API preserves explicit maxTokens", async () => {
let requestBody: any = null;
let didRetrieveModel = false;
const client = {
models: {
retrieve: async () => {
didRetrieveModel = true;
return { max_tokens: 128000 };
},
},
messages: {
create: async (body: any) => {
requestBody = body;
return streamFrom([
{
type: "message_start",
message: {
usage: { input_tokens: 1, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Done" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
]);
},
},
};
const events = await collectEvents(
streamWithMessagesApi({
client: client as any,
model: "claude-explicit-max-test",
messages: [{ role: "user", content: "Say done" }],
maxTokens: 4096,
})
);
assert.equal(didRetrieveModel, false);
assert.equal(requestBody?.max_tokens, 4096);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
});
test("fetch_url sends browser-like navigation headers", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}) as typeof fetch;
try {
let requestCount = 0;
const client = {
chat: {
completions: {
create: async () => {
requestCount += 1;
if (requestCount === 1) {
return {
choices: [
{
message: {
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "fetch_url",
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
},
},
],
},
},
],
};
}
return {
choices: [{ message: { content: "Fetched" } }],
};
},
},
},
};
const result = await completeWithChatCompletionsApi({
client: client as any,
model: "grok-test",
messages: [{ role: "user", content: "Fetch CPI PDF" }],
});
assert.equal(result.text, "Fetched");
assert.equal(fetchCalls.length, 1);
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
assert.deepEqual(fetchCalls[0]?.init?.headers, {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
});
assert.equal(result.toolEvents[0]?.status, "completed");
} finally {
globalThis.fetch = originalFetch;
}
});
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}) as typeof fetch;
try {
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestBodies.push(body);
if (requestBodies.length === 1) {
return {
content: [
{
type: "tool_use",
id: "toolu_1",
name: "fetch_url",
input: { url: "https://example.com/article" },
},
],
usage: { input_tokens: 3, output_tokens: 2 },
};
}
return {
content: [{ type: "text", text: "Fetched" }],
usage: { input_tokens: 5, output_tokens: 1 },
};
},
},
};
const result = await completeWithMessagesApi({
client: client as any,
model: "claude-test",
messages: [{ role: "user", content: "Fetch the article" }],
});
assert.equal(result.text, "Fetched");
assert.equal(fetchCalls.length, 1);
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
assert.equal(requestBodies.length, 2);
assert.equal(requestBodies[0]?.model, "claude-test");
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
assert.equal(fetchTool?.input_schema?.type, "object");
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
const secondMessages = requestBodies[1]?.messages ?? [];
assert.equal(secondMessages.at(-2)?.role, "assistant");
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
assert.equal(secondMessages.at(-1)?.role, "user");
const toolResult = secondMessages.at(-1)?.content?.[0];
assert.equal(toolResult?.type, "tool_result");
assert.equal(toolResult?.tool_use_id, "toolu_1");
assert.equal(toolResult?.is_error, false);
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
assert.equal(result.toolEvents[0]?.status, "completed");
assert.equal(result.usage?.inputTokens, 8);
assert.equal(result.usage?.outputTokens, 3);
assert.equal(result.usage?.totalTokens, 11);
} finally {
globalThis.fetch = originalFetch;
}
});
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
let requestCount = 0;
const client = {
chat: {
completions: {
create: async () => {
requestCount += 1;
if (requestCount === 1) {
return streamFrom([
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
function: {
name: "unknown_tool",
arguments: "{\"query\":\"current weather\"}",
},
},
],
},
finish_reason: "tool_calls",
},
],
},
]);
}
return streamFrom([
{ choices: [{ delta: { content: "Done" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]);
},
},
},
};
const events = await collectEvents(
streamWithChatCompletionsApi({
client: client as any,
model: "grok-test",
messages: [{ role: "user", content: "Use a tool" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["tool_call", "tool_call", "delta", "done"]
);
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
assert.equal(toolEvents[0]?.toolCallId, "call_1");
assert.equal(toolEvents[0]?.status, "initiated");
assert.equal(toolEvents[0]?.completedAt, undefined);
assert.equal(toolEvents[0]?.durationMs, undefined);
assert.equal(toolEvents[1]?.toolCallId, "call_1");
assert.equal(toolEvents[1]?.status, "failed");
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
assert.equal(typeof toolEvents[1]?.completedAt, "string");
assert.equal(typeof toolEvents[1]?.durationMs, "number");
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
});
test("Messages API stream emits initiated and terminal tool call updates", async () => {
let requestCount = 0;
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestCount += 1;
requestBodies.push(body);
if (requestCount === 1) {
return streamFrom([
{
type: "message_start",
message: {
usage: { input_tokens: 3, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "I'll check that." },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: {
type: "tool_use",
id: "toolu_1",
name: "unknown_tool",
input: {},
},
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
},
{ type: "content_block_stop", index: 1 },
{
type: "message_delta",
delta: { stop_reason: "tool_use", stop_sequence: null },
usage: { output_tokens: 2 },
},
{ type: "message_stop" },
]);
}
return streamFrom([
{
type: "message_start",
message: {
usage: { input_tokens: 4, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Done" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
]);
},
},
};
const events = await collectEvents(
streamWithMessagesApi({
client: client as any,
model: "claude-test",
messages: [{ role: "user", content: "Use a tool" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["tool_call", "tool_call", "delta", "done"]
);
assert.equal(requestBodies[0]?.stream, true);
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
const secondMessages = requestBodies[1]?.messages ?? [];
assert.equal(secondMessages.at(-2)?.role, "assistant");
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
const toolResult = secondMessages.at(-1)?.content?.[0];
assert.equal(toolResult?.type, "tool_result");
assert.equal(toolResult?.tool_use_id, "toolu_1");
assert.equal(toolResult?.is_error, true);
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
assert.equal(toolEvents[0]?.status, "initiated");
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
assert.equal(toolEvents[1]?.status, "failed");
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
});
-26
View File
@@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemPromptAugmentation, buildTopLevelSystemPrompt } from "../src/llm/message-content.js";
test("system prompt augmentation includes date and default location", () => {
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
});
test("system prompt augmentation uses provided user location", () => {
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
});
test("top-level system prompt includes runtime context with existing system messages", () => {
const prompt = buildTopLevelSystemPrompt(
[{ role: "system", content: "Use concise answers." }],
"Los Angeles, CA"
);
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
assert.match(prompt, /User location: Los Angeles, CA\./);
assert.match(prompt, /Use concise answers\./);
});
-42
View File
@@ -1,42 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), {
provider: "openai",
protocol: "chat-completions",
managedTools: false,
enabledTools: [],
});
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
provider: "openai",
protocol: "responses",
managedTools: true,
enabledTools: ["web_search"],
});
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
provider: "anthropic",
protocol: "messages",
managedTools: true,
enabledTools: ["web_search"],
});
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
provider: "xai",
protocol: "chat-completions",
managedTools: true,
enabledTools: ["web_search"],
});
assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), {
provider: "gemini",
protocol: "gemini",
managedTools: true,
enabledTools: ["web_search"],
});
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
provider: "hermes-agent",
protocol: "chat-completions",
managedTools: false,
enabledTools: [],
});
});
-14
View File
@@ -1,14 +0,0 @@
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.equal(toPrismaProvider("gemini"), "gemini");
assert.equal(fromPrismaProvider("gemini"), "gemini");
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
initiatedProvider: "hermes-agent",
lastUsedProvider: "gemini",
});
});
-25
View File
@@ -1,25 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
test("normalizeSearchQuery trims and lowercases query text", () => {
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
assert.equal(normalizeSearchQuery(" "), null);
assert.equal(normalizeSearchQuery(null), null);
});
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
const now = new Date("2026-05-31T12:00:00.000Z");
assert.equal(
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
true
);
assert.equal(
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
false
);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
});
+1 -1
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` | `hermes-agent` (default: `openai`)
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
-31
View File
@@ -10,7 +10,6 @@ import type {
SearchStreamHandlers,
SearchSummary,
SessionStatus,
WorkspaceItem,
} from "./types.js";
type RequestOptions = {
@@ -42,11 +41,6 @@ export class SybilApiClient {
return data.chats;
}
async listWorkspaceItems() {
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
async createChat(title?: string) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
@@ -60,22 +54,6 @@ export class SybilApiClient {
return data.chat;
}
async updateChatTitle(chatId: string, title: string) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: { title },
});
return data.chat;
}
async updateChatStar(chatId: string, starred: boolean) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
method: "PATCH",
body: { starred },
});
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
@@ -106,14 +84,6 @@ export class SybilApiClient {
return data.search;
}
async updateSearchStar(searchId: string, starred: boolean) {
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
method: "PATCH",
body: { starred },
});
return data.search;
}
async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
@@ -124,7 +94,6 @@ export class SybilApiClient {
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
userLocation?: string;
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
function normalizeBaseUrl(value: string) {
const trimmed = value.trim();
+82 -314
View File
@@ -11,7 +11,6 @@ import type {
SearchDetail,
SearchSummary,
ToolCallEvent,
WorkspaceItem,
} from "./types.js";
type SidebarSelection = { kind: "chat" | "search"; id: string };
@@ -20,8 +19,6 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -32,7 +29,7 @@ type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "initiated" | "completed" | "failed";
status?: "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
startedAt?: string;
@@ -42,21 +39,17 @@ type ToolLogMetadata = {
resultPreview?: string | null;
};
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
"hermes-agent": ["hermes-agent"],
};
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null },
gemini: { models: [], loadedAt: null, error: null },
};
function escapeTags(value: string) {
@@ -81,8 +74,6 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
if (provider === "gemini") return "Gemini";
if (provider === "hermes-agent") return "Hermes Agent";
return "";
}
@@ -99,73 +90,33 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search";
}
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
return { type: "chat", ...chat };
}
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
return { type: "search", ...search };
}
function splitWorkspaceItems(items: WorkspaceItem[]) {
const chats: ChatSummary[] = [];
const searches: SearchSummary[] = [];
for (const item of items) {
if (item.type === "chat") {
const { type: _type, ...chat } = item;
chats.push(chat);
} else {
const { type: _type, ...search } = item;
searches.push(search);
}
}
return { chats, searches };
}
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
}
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
const chatsById = new Map<string, ChatSummary>();
for (const item of items) {
if (item.type === "chat") chatsById.set(item.id, item);
}
return items.map((item) => {
if (item.type === "chat") {
const chat = item;
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
return {
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
starred: starOwner.starred,
starredAt: starOwner.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
};
}
const search = item;
return {
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
const items: SidebarItem[] = [
...chats.map((chat) => ({
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
})),
...searches.map((search) => ({
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
lastUsedModel: null,
};
});
})),
];
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -180,57 +131,34 @@ function isToolCallLogMessage(message: Message) {
}
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
const metadata: ToolLogMetadata = {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
};
if (event.completedAt) metadata.completedAt = event.completedAt;
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
return {
id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
createdAt: event.completedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
metadata,
metadata: {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
};
}
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
const toolMessage = buildOptimisticToolMessage(event);
const existingIndex = messages.findIndex(
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
);
if (existingIndex >= 0) {
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
}
const assistantIndex = messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) return messages.concat(toolMessage);
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
}
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
const providerModels = catalog[provider]?.models ?? [];
if (providerModels.length) return providerModels;
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;
@@ -260,7 +188,6 @@ async function main() {
let authMode: "open" | "token" | null = null;
let chats: ChatSummary[] = [];
let searches: SearchSummary[] = [];
let workspaceItems: WorkspaceItem[] = [];
let selectedItem: SidebarSelection | null = null;
let selectedChat: ChatDetail | null = null;
let selectedSearch: SearchDetail | null = null;
@@ -275,8 +202,6 @@ async function main() {
openai: null,
anthropic: null,
xai: null,
gemini: null,
"hermes-agent": null,
};
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
let errorMessage: string | null = null;
@@ -289,7 +214,6 @@ async function main() {
let renderedSidebarItems: SidebarItem[] = [];
let renderedSidebarLines: string[] = [];
let suppressedSidebarSelectEvents = 0;
let isRenamePromptOpen = false;
const screen = blessed.screen({
smartCSR: true,
@@ -397,26 +321,6 @@ async function main() {
},
});
const renamePrompt = (blessed as any).prompt({
parent: screen,
label: " Rename chat ",
border: "line",
tags: true,
keys: true,
vi: true,
mouse: true,
top: "center",
left: "center",
width: "50%",
height: "shrink",
hidden: true,
style: {
border: { fg: "cyan" },
label: { fg: "cyan" },
fg: "white",
},
});
const focusables = [sidebar, transcript, composer] as const;
function getTranscriptViewportHeight() {
@@ -465,7 +369,7 @@ async function main() {
}
function getSidebarItems() {
return buildSidebarItems(workspaceItems);
return buildSidebarItems(chats, searches);
}
function getSelectedChatSummary() {
@@ -556,13 +460,12 @@ async function main() {
? ["No chats/searches yet. Press n or /. "]
: items.map((item) => {
const kind = item.kind === "chat" ? "C" : "S";
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
const title = truncate(item.title, 36);
const initiatedLabel =
item.kind === "chat" && item.initiatedModel
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
: "";
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
});
const linesChanged =
@@ -631,12 +534,7 @@ async function main() {
for (const message of messages) {
const toolMeta = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolMeta) {
const prefix =
toolMeta.status === "failed"
? "{red-fg}[tool failed]{/red-fg}"
: toolMeta.status === "initiated"
? "{yellow-fg}[tool running]{/yellow-fg}"
: "{cyan-fg}[tool]{/cyan-fg}";
const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}";
const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
parts.push(`${prefix} ${escapeTags(summary)}`);
continue;
@@ -742,7 +640,7 @@ async function main() {
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
let controls =
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
if (!isSearchMode) {
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
@@ -795,7 +693,6 @@ async function main() {
function resetWorkspaceState() {
chats = [];
searches = [];
workspaceItems = [];
selectedItem = null;
selectedChat = null;
selectedSearch = null;
@@ -862,13 +759,11 @@ async function main() {
updateUI();
try {
const nextWorkspaceItems = await api.listWorkspaceItems();
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
workspaceItems = nextWorkspaceItems;
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
chats = nextChats;
searches = nextSearches;
const nextItems = buildSidebarItems(nextWorkspaceItems);
const nextItems = buildSidebarItems(nextChats, nextSearches);
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
selectedItem = options.preferredSelection;
draftKind = null;
@@ -904,27 +799,6 @@ async function main() {
composer.readInput();
}
function shouldIgnoreGlobalShortcut() {
return isRenamePromptOpen || isTextInputFocused(screen, composer);
}
function promptForChatTitle(currentTitle: string) {
isRenamePromptOpen = true;
updateUI();
return new Promise<string | null>((resolve) => {
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
isRenamePromptOpen = false;
renamePrompt.hide();
screen.render();
if (err || value === null || value === undefined) {
resolve(null);
return;
}
resolve(value);
});
});
}
function cycleFocus(step: 1 | -1) {
const focused = screen.focused;
const currentIndex = focusables.findIndex((node) => node === focused);
@@ -985,30 +859,17 @@ async function main() {
focusComposer();
}
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
async function maybeSuggestTitle(chatId: string, content: string) {
const chatSummary = chats.find((chat) => chat.id === chatId);
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
const chatId = chat.id;
pendingTitleGeneration.add(chatId);
try {
const updated = await api.suggestChatTitle({ chatId, content });
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
}
updateUI();
} catch {
@@ -1051,18 +912,13 @@ async function main() {
chatId = chat.id;
draftKind = null;
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
selectedItem = { kind: "chat", id: chat.id };
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
selectedChat = {
id: chat.id,
title: chat.title,
parentChatId: chat.parentChatId,
titleGenerationPending: chat.titleGenerationPending,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -1078,13 +934,13 @@ async function main() {
throw new Error("Unable to initialize chat");
}
void maybeSuggestTitle(chatId, content);
let baseChat = selectedChat;
if (!baseChat || baseChat.id !== chatId) {
baseChat = await api.getChat(chatId);
}
void maybeSuggestTitle(baseChat, content);
const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages
.filter((message) => !isToolCallLogMessage(message))
@@ -1121,7 +977,29 @@ async function main() {
},
onToolCall: (payload) => {
if (!pendingChatState) return;
pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
const alreadyPresent = pendingChatState.messages.some(
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
);
if (alreadyPresent) return;
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = pendingChatState.messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) {
pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) };
} else {
pendingChatState = {
...pendingChatState,
messages: [
...pendingChatState.messages.slice(0, assistantIndex),
toolMessage,
...pendingChatState.messages.slice(assistantIndex),
],
};
}
queueTranscriptScrollToBottomIfFollowing();
updateUI();
@@ -1199,7 +1077,6 @@ async function main() {
draftKind = null;
selectedItem = { kind: "search", id: searchId };
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
selectedChat = null;
forceScrollToBottom = true;
updateUI();
@@ -1217,8 +1094,6 @@ async function main() {
query,
createdAt: nowIso,
updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null,
latencyMs: null,
error: null,
@@ -1381,102 +1256,9 @@ async function main() {
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
}
async function handleRenameSelection() {
if (!selectedItem || selectedItem.kind !== "chat") return;
const chatId = selectedItem.id;
const summary = chats.find((chat) => chat.id === chatId);
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
const value = await promptForChatTitle(currentTitle);
const title = value?.trim();
if (!title) {
updateUI();
return;
}
setError(null);
const updated = await api.updateChatTitle(chatId, title);
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
updateUI();
}
async function handleToggleStarSelection() {
if (!selectedItem) return;
setError(null);
if (selectedItem.kind === "chat") {
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
const selectedParentChatId = selectedChat?.id === selectedItem.id
? selectedChat.parentChatId
: selectedSummary?.parentChatId;
const rootChatId = selectedParentChatId ?? selectedItem.id;
const rootSummary = chats.find((chat) => chat.id === rootChatId);
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
}
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
} else {
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
searches = searches.map((search) => (search.id === updated.id ? updated : search));
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
}
if (selectedSearch?.id === updated.id) {
selectedSearch = {
...selectedSearch,
title: updated.title,
query: updated.query,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
};
}
}
updateUI();
}
function cycleProvider() {
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";
const currentIndex = PROVIDERS.indexOf(provider);
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
provider = nextProvider;
syncModelForProvider();
updateUI();
@@ -1557,18 +1339,18 @@ async function main() {
});
screen.key(["q"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
screen.destroy();
process.exit(0);
});
screen.key(["tab"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
cycleFocus(1);
});
screen.key(["S-tab", "backtab"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
cycleFocus(-1);
});
@@ -1585,50 +1367,36 @@ async function main() {
});
screen.key(["n"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
handleCreateChat();
});
screen.key(["/"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
handleCreateSearch();
});
screen.key(["d"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
void runAction(async () => {
await handleDeleteSelection();
});
});
screen.key(["s"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleToggleStarSelection();
});
});
screen.key(["p"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleProvider();
});
screen.key(["m"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleModel();
});
screen.key(["r"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleRenameSelection();
});
});
screen.key(["C-r"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
void runAction(async () => {
await refreshCollections({ loadSelection: true });
await refreshModels();
+5 -27
View File
@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
export type Provider = "openai" | "anthropic" | "xai";
export type ProviderModelInfo = {
models: string[];
@@ -7,18 +7,14 @@ export type ProviderModelInfo = {
};
export type ModelCatalogResponse = {
providers: Partial<Record<Provider, ProviderModelInfo>>;
providers: Record<Provider, ProviderModelInfo>;
};
export type ChatSummary = {
id: string;
title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -31,20 +27,8 @@ export type SearchSummary = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export type ChatWorkspaceItem = ChatSummary & {
type: "chat";
};
export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type Message = {
id: string;
createdAt: string;
@@ -57,12 +41,12 @@ export type Message = {
export type ToolCallEvent = {
toolCallId: string;
name: string;
status: "initiated" | "completed" | "failed";
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt?: string;
durationMs?: number;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
@@ -70,12 +54,8 @@ export type ToolCallEvent = {
export type ChatDetail = {
id: string;
title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -105,8 +85,6 @@ export type SearchDetail = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null;
latencyMs: number | null;
error: string | null;
-4
View File
@@ -40,10 +40,6 @@ 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`
+1 -7
View File
@@ -3,18 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Sybil chat and search workspace" />
<meta name="application-name" content="Sybil" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Sybil" />
<meta name="format-detection" content="telephone=no" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
<title>Sybil</title>
</head>
-1
View File
@@ -7,7 +7,6 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "node --test --experimental-strip-types tests/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {

Some files were not shown because too many files have changed in this diff Show More