Compare commits

..
24 Commits
Author SHA1 Message Date
buzzert 5f9fc82b86 adds quick question UI for mac catalyst target 2026-08-21 17:28:08 -07:00
buzzert eb2b0d3ca0 Add chat thread forking
TestFlight / Build and upload (push) Successful in 1m52s
2026-08-16 17:42:39 -07:00
buzzert 42022bf055 Improve chat composer typing performance 2026-08-13 00:08:15 -07:00
buzzert 1ef491f39a Add foldable split-pane layout 2026-08-06 23:28:42 -07:00
buzzert b540f01759 Fix Sybil release paths
TestFlight / Build and upload (push) Successful in 1m50s
2026-07-27 17:36:31 -07:00
buzzert e719cbb92d Simplify iOS TestFlight automation
TestFlight / Build and upload (push) Failing after 18s
2026-07-27 17:29:47 -07:00
buzzert cb5a973ac7 Preserve provider changes before sending 2026-07-26 17:44:45 -07:00
buzzert e2443162b0 Preserve thread selection during background updates 2026-07-26 17:37:39 -07:00
buzzert 048456b8a5 Preserve draft views during collection refreshes 2026-07-25 17:14:39 -07:00
buzzert 211fad972a avoid mobile focus on conversation selection 2026-07-23 20:29:53 -07:00
buzzert a69c641481 add mobile workspace gestures 2026-07-23 18:05:57 -07:00
buzzert 9229896ad7 harden PWA resume and title fallback 2026-07-23 17:29:02 -07:00
buzzert c5217b2710 make PWA chat resume idempotent 2026-07-23 17:12:11 -07:00
buzzert abc1124d27 fix for PWA background/foreground 2026-07-23 16:32:20 -07:00
buzzert 4721717022 server: respect Brave search rate limits 2026-07-19 21:56:35 -07:00
buzzert 87b7d9502f server: add Brave search provider 2026-07-19 21:35:47 -07:00
buzzert 1952f4f358 web: open links in chat in new tab 2026-07-19 16:08:38 -07:00
buzzert 622659f6ca ios: preserve chat scroll position on resume
TestFlight / testflight (push) Successful in 1m46s
2026-07-12 13:57:45 -07:00
buzzert 93ca8a76c3 adds gemini support
TestFlight / testflight (push) Successful in 1m53s
2026-07-11 14:16:21 -07:00
buzzertandClaude Fable 5 69f50064a3 ci: restore Ruby PATH export for fastlane step
TestFlight / testflight (push) Successful in 1m58s
act_runner does not carry setup-ruby's PATH changes into later steps,
so without this the step runs the toolcache default Ruby (3.3.11) and
bundler cannot find the gems installed for 3.1.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:04:47 -07:00
buzzertandClaude Fable 5 e7b81f24bd ci: put CI keychain first in codesign search list
TestFlight / testflight (push) Failing after 19s
codesign resolves signing identities through the user keychain search
list (first match wins) and ignores --keychain for the lookup. This
runner hosts another project whose keychain holds the same Apple
Distribution identity, so when that keychain is locked, codesign fails
with errSecInternalComponent no matter how correctly our own keychain
is set up. Prepend the fresh CI keychain to the search list for the
build and always delete it afterward, which restores the original list.

Also drop the runner-diagnostics step, the GitHub-hosted Ruby PATH
export, and the duplicate CODE_SIGN_IDENTITY param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:01:42 -07:00
buzzert 20a310a4b1 ci: use Ruby 3.1 for TestFlight upload
TestFlight / testflight (push) Failing after 46s
2026-07-11 12:01:27 -07:00
buzzert b6859706db ios: simplify CI signing keychain setup
TestFlight / testflight (push) Failing after 25s
2026-07-11 11:55:04 -07:00
buzzert ea148839d3 Revert "ios: pass match keychain to codesign"
This reverts commit a0e410155d.
2026-07-11 11:32:00 -07:00
71 changed files with 6039 additions and 974 deletions
+15 -28
View File
@@ -1,52 +1,39 @@
name: TestFlight name: TestFlight
on: on:
workflow_dispatch:
push: push:
tags: tags:
- "release/ios/v*" - "release/ios/v*.*.*"
jobs: jobs:
testflight: testflight:
runs-on: xcode name: Build and upload
defaults: runs-on: macos-arm64
run: timeout-minutes: 90
shell: bash
steps: steps:
- name: Checkout - name: Check out the release tag
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: "3.1.7" ruby-version: "3.3.11"
bundler-cache: true bundler-cache: true
working-directory: ios working-directory: ios
- name: Install XcodeGen - name: Install XcodeGen
run: | run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
set -euo pipefail
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Upload to TestFlight - name: Build and upload to TestFlight
working-directory: ios working-directory: ios
env: env:
HOME: /var/lib/act_runner ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} ASC_KEY: ${{ secrets.ASC_KEY }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
SYBIL_BUILD_NUMBER: ${{ github.run_number }} CI: "true"
FASTLANE_SKIP_UPDATE_CHECK: "1" FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120" FASTLANE_HIDE_CHANGELOG: "1"
run: | run: bundle exec fastlane ios beta
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
ruby --version
bundle exec fastlane ios beta
+26
View File
@@ -12,17 +12,43 @@ server {
location /api/ { location /api/ {
proxy_pass http://server:8787/; proxy_pass http://server:8787/;
proxy_http_version 1.1; 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 Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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 { location = /manifest.webmanifest {
default_type application/manifest+json; 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; try_files $uri =404;
} }
location / { location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }
+2
View File
@@ -12,10 +12,12 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_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_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-} HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-} HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
EXA_API_KEY: ${EXA_API_KEY:-} EXA_API_KEY: ${EXA_API_KEY:-}
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa} CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-} SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100} CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
+57 -12
View File
@@ -34,11 +34,13 @@ Chat upload limits:
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "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 }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }, "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null } "hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
} }
} }
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - 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. - `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. - 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.
@@ -56,7 +58,7 @@ Chat upload limits:
``` ```
Behavior notes: Behavior notes:
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions. - 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. - Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
## Active Runs ## Active Runs
@@ -87,6 +89,8 @@ Behavior notes:
"type": "chat", "type": "chat",
"id": "chat-id", "id": "chat-id",
"title": "optional title", "title": "optional title",
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "2026-02-14T00:00:00.000Z", "createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z", "updatedAt": "2026-02-14T00:00:00.000Z",
"starred": true, "starred": true,
@@ -115,7 +119,8 @@ Behavior notes:
Behavior notes: Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars. - 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 legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines up to 100 chats and up to 100 searches. - 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. - `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
## Chats ## Chats
@@ -128,7 +133,7 @@ Behavior notes:
```json ```json
{ {
"title": "optional title", "title": "optional title",
"provider": "optional openai|anthropic|xai|hermes-agent", "provider": "optional openai|anthropic|xai|gemini|hermes-agent",
"model": "optional model id", "model": "optional model id",
"additionalSystemPrompt": "optional stored system prompt", "additionalSystemPrompt": "optional stored system prompt",
"enabledTools": ["web_search", "fetch_url"], "enabledTools": ["web_search", "fetch_url"],
@@ -146,15 +151,32 @@ Behavior notes:
Behavior notes: Behavior notes:
- `provider` and `model` must be supplied together when present. - `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`. - 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`. - `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. - `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. - 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` ### `PATCH /v1/chats/:chatId`
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }` - Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
- Response: `{ "chat": ChatSummary }` - Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title. - 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`. - `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. - `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
- Updating chat fields changes the returned chat's `updatedAt`. - Updating chat fields changes the returned chat's `updatedAt`.
@@ -181,13 +203,19 @@ Behavior notes:
- Response: `{ "chat": ChatSummary }` - Response: `{ "chat": ChatSummary }`
Behavior notes: Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged. - 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. - If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- When no title exists at write time, 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. - 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.
### `DELETE /v1/chats/:chatId` ### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }` - Response: `{ "deleted": true }`
- Not found: `404 { "message": "chat not found" }` - 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` ### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }` - Response: `{ "chat": ChatDetail }`
@@ -234,7 +262,7 @@ Notes:
```json ```json
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"provider": "openai|anthropic|xai|hermes-agent", "provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -294,13 +322,16 @@ Behavior notes:
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction. - 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 `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 `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 `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 `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 `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 `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. - 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`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available. - 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, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. - `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). - `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. - `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. - `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.
@@ -318,6 +349,16 @@ Behavior notes:
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (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. - 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`.
## Searches ## Searches
### `GET /v1/searches` ### `GET /v1/searches`
@@ -413,13 +454,15 @@ Behavior notes:
{ {
"id": "...", "id": "...",
"title": null, "title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"starred": false, "starred": false,
"starredAt": null, "starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null", "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedModel": "string|null", "lastUsedModel": "string|null",
"additionalSystemPrompt": null, "additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"] "enabledTools": ["web_search", "fetch_url"]
@@ -465,13 +508,15 @@ Behavior notes:
{ {
"id": "...", "id": "...",
"title": null, "title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"starred": false, "starred": false,
"starredAt": null, "starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null", "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedModel": "string|null", "lastUsedModel": "string|null",
"additionalSystemPrompt": null, "additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"], "enabledTools": ["web_search", "fetch_url"],
+13 -4
View File
@@ -21,7 +21,8 @@ Authentication:
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"persist": true, "persist": true,
"provider": "openai|anthropic|xai|hermes-agent", "clientRequestId": "optional-client-generated-id",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -61,6 +62,9 @@ Notes:
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat. - If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists. - If `chatId` is provided, backend validates it exists.
- 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. - 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. - 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. - `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. - `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.
@@ -69,8 +73,10 @@ Notes:
Persisted chat streams with a `chatId` are backend-owned active runs: 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. - 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`. - While running, `GET /v1/active-runs` includes the `chatId`.
- Starting a second persisted stream for the same active `chatId` returns `409`. - 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`. - Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
## Attach Endpoint ## Attach Endpoint
@@ -174,18 +180,21 @@ Terminal tool-call event:
- `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. - `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. - `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. - `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. - `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. - `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. - `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. - `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. - `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. - `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, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints. - `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. - `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. - `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. - `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`): Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
- Stream still emits standard `meta`, `delta`, `done|error` events. - Stream still emits standard `meta`, `delta`, `done|error` events.
- Stream may emit `tool_call` events while tool calls are executed. - 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. - `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.
+5 -24
View File
@@ -1,24 +1,5 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2 ASC_KEY_ID=
FASTLANE_TEAM_ID=DQQH5H6GBD ASC_ISSUER_ID=
FASTLANE_SKIP_UPDATE_CHECK=1 ASC_KEY=
FASTLANE_HIDE_CHANGELOG=1 MATCH_PASSWORD=
SYBIL_APP_STORE_APPLE_ID=6759442828 MATCH_GIT_BASIC_AUTHORIZATION=
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
SYBIL_PROVISIONING_PROFILE_UUID=
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
SYBIL_SIGNING_CERTIFICATE_ID=
SYBIL_SIGNING_KEYCHAIN=
# App Store Connect API key settings for TestFlight upload and signing setup.
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_PATH=
APP_STORE_CONNECT_API_KEY_CONTENT=
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
# Optional deployment overrides.
SYBIL_BUILD_NUMBER=
SYBIL_VERSION_TAG=
+1
View File
@@ -57,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini` - xAI: `grok-3-mini`
- Gemini: `gemini-3.5-flash`
- Hermes Agent: `hermes-agent` - Hermes Agent: `hermes-agent`
+3
View File
@@ -2,6 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>UIApplicationShortcutItems</key> <key>UIApplicationShortcutItems</key>
<array> <array>
<dict> <dict>
+21 -1
View File
@@ -8,7 +8,7 @@ struct SybilApp: App
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup(id: SybilCommands.workspaceWindowID) {
SplitView() SplitView()
} }
.commands { .commands {
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool { ) -> Bool {
SybilHomeScreenQuickActionHandler.configureQuickActions() SybilHomeScreenQuickActionHandler.configureQuickActions()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.start()
#endif
return true return true
} }
#if targetEnvironment(macCatalyst)
func applicationWillTerminate(_ application: UIApplication) {
SybilMacQuickQuestionController.shared.stop()
}
#endif
func application( func application(
_ application: UIApplication, _ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession, configurationForConnecting connectingSceneSession: UISceneSession,
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
willConnectTo session: UISceneSession, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions 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 { if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem) _ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
} }
} }
#if targetEnvironment(macCatalyst)
func sceneDidDisconnect(_ scene: UIScene) {
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
}
#endif
func windowScene( func windowScene(
_ windowScene: UIWindowScene, _ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem, performActionFor shortcutItem: UIApplicationShortcutItem,
+8
View File
@@ -11,6 +11,13 @@ targets:
dependencies: dependencies:
- package: Sybil - package: Sybil
product: Sybil product: Sybil
- target: SybilMacQuickQuestion
destinationFilters: [macCatalyst]
embed: true
link: false
codeSign: true
copy:
destination: plugins
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2 PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
@@ -29,6 +36,7 @@ targets:
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
@@ -0,0 +1,229 @@
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?() }
}
@@ -0,0 +1,300 @@
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)
}
}
@@ -0,0 +1,25 @@
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
+1 -1
View File
@@ -1,3 +1,3 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "fastlane" gem "fastlane", "2.237.0"
+75 -62
View File
@@ -1,46 +1,49 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.9) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.9.0) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.3.2) aws-eventstream (1.4.0)
aws-partitions (1.1109.0) aws-partitions (1.1274.0)
aws-sdk-core (3.224.1) aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
base64 base64
bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.101.0) aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0) aws-sdk-s3 (1.228.1)
aws-sdk-core (~> 3, >= 3.224.1) aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.2.0) base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
commander (4.6.0) commander (4.6.0)
highline (~> 2.0.0) highline (~> 2.0.0)
csv (3.3.5) csv (3.3.6)
declarative (0.0.20) declarative (0.0.20)
digest-crc (0.7.0) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701) domain_name (0.6.20240107)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.109.0) excon (1.6.0)
logger
faraday (1.10.6) faraday (1.10.6)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
@@ -70,42 +73,45 @@ GEM
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.1) fastimage (2.4.1)
fastlane (2.230.0) fastlane (2.237.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1.2) abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0) aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0) babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0) base64 (~> 0.2)
bundler (>= 1.12.0, < 3.0.0) benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2) colored (~> 1.2)
commander (~> 4.6) commander (~> 4.6)
csv (~> 3.3) csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0) dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0) emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0) excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0) faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0) fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5) http-cookie (~> 1.0.5)
json (< 3.0.0) json (< 3.0.0)
jwt (>= 2.1.0, < 3) jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0) logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0) mutex_m (~> 0.3)
naturally (~> 2.2) naturally (~> 2.2)
nkf (~> 0.2.0) nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0) optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5) security (= 0.1.5)
@@ -120,41 +126,46 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0) fastlane-sirp (1.1.0)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0) google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.11.3) google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml google-apis-iamcredentials_v1 (0.28.0)
google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.18.0)
google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.65.0)
google-apis-storage_v1 (0.29.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.9.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0) google-cloud-env (2.2.2)
faraday (>= 0.17.3, < 3.0) base64 (~> 0.2)
google-cloud-errors (1.3.1) faraday (>= 1.0, < 3.a)
google-cloud-storage (1.45.0) google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-core (>= 0.18, < 2)
google-apis-storage_v1 (~> 0.29.0) google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.8.1) google-logging-utils (0.2.0)
faraday (>= 0.17.3, < 3.a) googleauth (1.17.2)
jwt (>= 1.4, < 3.0) faraday (>= 1.0, < 3.a)
multi_json (~> 1.11) google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
highline (2.0.3) highline (2.0.3)
http-cookie (1.0.8) http-cookie (1.0.8)
@@ -162,22 +173,24 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.6) json (2.21.1)
jwt (2.10.3) jwt (3.2.0)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.15.0) multi_json (1.21.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
naturally (2.3.0) naturally (2.3.0)
nkf (0.2.0) nkf (0.3.0)
optparse (0.8.1) optparse (0.8.1)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (5.1.1) pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2) rake (13.4.2)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -189,11 +202,10 @@ GEM
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
security (0.1.5) security (0.1.5)
signet (0.18.0) signet (0.22.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
@@ -206,15 +218,16 @@ GEM
tty-spinner (0.9.3) tty-spinner (0.9.3)
tty-cursor (~> 0.7) tty-cursor (~> 0.7)
uber (0.1.0) uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0) unicode-display_width (2.6.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.27.0) xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3) atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0) claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1) colored2 (~> 3.1)
nanaimo (~> 0.4.0) nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0) rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1) xcpretty (0.4.1)
rouge (~> 3.28.0) rouge (~> 3.28.0)
@@ -225,7 +238,7 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
fastlane fastlane (= 2.237.0)
BUNDLED WITH BUNDLED WITH
2.5.23 2.5.23
@@ -78,6 +78,7 @@ public struct SplitView: View {
.font(.sybil(.body)) .font(.sybil(.body))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions) .focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
#if !targetEnvironment(macCatalyst)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) { .sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView( SybilQuickQuestionView(
viewModel: viewModel, viewModel: viewModel,
@@ -85,7 +86,11 @@ public struct SplitView: View {
) )
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
#endif
.task { .task {
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
await viewModel.bootstrap() await viewModel.bootstrap()
presentPendingQuickQuestionIfPossible() presentPendingQuickQuestionIfPossible()
} }
@@ -107,6 +112,9 @@ public struct SplitView: View {
shouldRefreshOnForeground = true shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork() viewModel.markAppInactiveForNetwork()
case .active: case .active:
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
viewModel.markAppActiveForNetwork() viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else { guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return return
@@ -151,8 +159,13 @@ public struct SplitView: View {
} }
hasPendingQuickQuestionPresentation = false hasPendingQuickQuestionPresentation = false
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
SybilMacQuickQuestionController.shared.show()
#else
quickQuestionFocusRequest += 1 quickQuestionFocusRequest += 1
isQuickQuestionPresented = true isQuickQuestionPresented = true
#endif
} }
private func handleQuickQuestionDismissed() { private func handleQuickQuestionDismissed() {
@@ -161,23 +174,57 @@ public struct SplitView: View {
} }
public struct SybilCommands: Commands { public struct SybilCommands: Commands {
public static let workspaceWindowID = "sybil-workspace"
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions @FocusedValue(\.sybilKeyboardActions) private var keyboardActions
#if targetEnvironment(macCatalyst)
@Environment(\.openWindow) private var openWindow
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
#endif
public init() {} public init() {}
public var body: some Commands { public var body: some Commands {
CommandGroup(replacing: .newItem) { CommandGroup(replacing: .newItem) {
Button("New Chat") { Button("New Chat") {
#if targetEnvironment(macCatalyst)
windowCommands.newChatOrWindow(
newChat: keyboardActions?.newChat,
openWindow: openNewWindow
)
#else
keyboardActions?.newChat() keyboardActions?.newChat()
#endif
} }
.keyboardShortcut("n", modifiers: .command) .keyboardShortcut("n", modifiers: .command)
#if targetEnvironment(macCatalyst)
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
#else
.disabled(keyboardActions == nil) .disabled(keyboardActions == nil)
#endif
#if targetEnvironment(macCatalyst)
Button("New Window", action: openNewWindow)
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
Button("New Search") { Button("New Search") {
keyboardActions?.newSearch() keyboardActions?.newSearch()
} }
#if targetEnvironment(macCatalyst)
.keyboardShortcut("n", modifiers: [.command, .option])
#else
.keyboardShortcut("n", modifiers: [.command, .shift]) .keyboardShortcut("n", modifiers: [.command, .shift])
#endif
.disabled(keyboardActions == nil) .disabled(keyboardActions == nil)
#if targetEnvironment(macCatalyst)
Divider()
Button("Quick Question") {
SybilMacQuickQuestionController.shared.toggle()
}
.keyboardShortcut(.space, modifiers: .option)
#endif
} }
CommandMenu("Conversation") { CommandMenu("Conversation") {
@@ -194,6 +241,13 @@ public struct SybilCommands: Commands {
.disabled(keyboardActions == nil) .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 { private struct SybilKeyboardActions {
@@ -67,7 +67,6 @@ struct SybilChatTranscriptView: View {
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onAppear { .onAppear {
syncKnownToolCallMessageIDs() syncKnownToolCallMessageIDs()
scrollToBottom(with: proxy, animated: false)
} }
.onChange(of: toolCallMessageIDSignature) { _, _ in .onChange(of: toolCallMessageIDSignature) { _, _ in
syncKnownToolCallMessageIDs() syncKnownToolCallMessageIDs()
@@ -0,0 +1,71 @@
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
}
}
@@ -0,0 +1,257 @@
#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
@@ -0,0 +1,37 @@
#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
@@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai case openai
case anthropic case anthropic
case xai case xai
case gemini
case hermesAgent = "hermes-agent" case hermesAgent = "hermes-agent"
public var displayName: String { public var displayName: String {
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case .openai: return "OpenAI" case .openai: return "OpenAI"
case .anthropic: return "Anthropic" case .anthropic: return "Anthropic"
case .xai: return "xAI" case .xai: return "xAI"
case .gemini: return "Gemini"
case .hermesAgent: return "Hermes Agent" case .hermesAgent: return "Hermes Agent"
} }
} }
@@ -152,6 +154,8 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable { public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var title: String? public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false public var starred = false
@@ -162,6 +166,39 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var lastUsedModel: String? 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 struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var title: String? public var title: String?
@@ -182,6 +219,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var title: String? public var title: String?
public var query: String? public var query: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false public var starred = false
@@ -196,6 +235,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.id = chat.id self.id = chat.id
self.title = chat.title self.title = chat.title
self.query = nil self.query = nil
self.parentChatId = chat.parentChatId
self.titleGenerationPending = chat.titleGenerationPending
self.createdAt = chat.createdAt self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt self.updatedAt = chat.updatedAt
self.starred = chat.starred self.starred = chat.starred
@@ -211,6 +252,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.id = search.id self.id = search.id
self.title = search.title self.title = search.title
self.query = search.query self.query = search.query
self.parentChatId = nil
self.titleGenerationPending = false
self.createdAt = search.createdAt self.createdAt = search.createdAt
self.updatedAt = search.updatedAt self.updatedAt = search.updatedAt
self.starred = search.starred self.starred = search.starred
@@ -226,6 +269,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
return ChatSummary( return ChatSummary(
id: id, id: id,
title: title, title: title,
parentChatId: parentChatId,
titleGenerationPending: titleGenerationPending,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
starred: starred, starred: starred,
@@ -251,6 +296,43 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
} }
} }
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 { public struct Message: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var createdAt: Date public var createdAt: Date
@@ -389,6 +471,8 @@ public enum JSONValue: Codable, Hashable, Sendable {
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable { public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var title: String? public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false public var starred = false
@@ -400,6 +484,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var messages: [Message] 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 struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var createdAt: Date public var createdAt: Date
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@FocusState private var promptFocused: Bool @FocusState private var promptFocused: Bool
@State private var promptSelection: TextSelection?
private var hasAnswerContent: Bool { private var hasAnswerContent: Bool {
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil !viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
return return
} }
promptFocused = true promptFocused = true
let prompt = viewModel.quickQuestionPrompt
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
} }
} }
@@ -94,6 +97,7 @@ struct SybilQuickQuestionView: View {
get: { viewModel.quickQuestionPrompt }, get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) } set: { viewModel.updateQuickQuestionPrompt($0) }
), ),
selection: $promptSelection,
axis: .vertical axis: .vertical
) )
.focused($promptFocused) .focused($promptFocused)
@@ -0,0 +1,35 @@
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
}
}
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel" static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel" static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel" static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel" static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider" static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel" static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel" static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel" static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel" static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
} }
@@ -44,6 +46,7 @@ final class SybilSettingsStore {
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini", .openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest", .anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini", .xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent" .hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
] ]
self.preferredModelByProvider = preferredModels self.preferredModelByProvider = preferredModels
@@ -54,6 +57,7 @@ final class SybilSettingsStore {
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini", .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", .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini", .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" .hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
] ]
} }
@@ -72,12 +76,14 @@ final class SybilSettingsStore {
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel) defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel) defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel) defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel) defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider) defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel) defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel) defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel) defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel) defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
} }
@@ -160,6 +160,7 @@ final class SybilViewModel {
.openai: ["gpt-4.1-mini"], .openai: ["gpt-4.1-mini"],
.anthropic: ["claude-3-5-sonnet-latest"], .anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"], .xai: ["grok-3-mini"],
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
.hermesAgent: ["hermes-agent"] .hermesAgent: ["hermes-agent"]
] ]
@@ -405,14 +406,17 @@ final class SybilViewModel {
} else { } else {
initiatedLabel = nil initiatedLabel = nil
} }
let starOwner = item.parentChatId.flatMap { parentChatID in
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
} ?? item
return SidebarItem( return SidebarItem(
selection: .chat(item.id), selection: .chat(item.id),
kind: .chat, kind: .chat,
title: chatTitle(title: item.title, messages: nil), title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
starred: item.starred, starred: starOwner.starred,
starredAt: item.starredAt, starredAt: starOwner.starredAt,
initiatedLabel: initiatedLabel, initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id) isRunning: isChatRowRunning(item.id)
) )
@@ -632,9 +636,12 @@ final class SybilViewModel {
} }
cancelQuickQuestion() cancelQuickQuestion()
// Lock submission before the task starts, including repeated Return
// events delivered in the same main-actor turn.
isQuickQuestionSending = true
let selectedProvider = quickQuestionProvider let selectedProvider = quickQuestionProvider
let task = Task { [weak self] in let task = Task { [weak self] in
guard let self else { guard let self, !Task.isCancelled else {
return return
} }
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel) await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
@@ -686,6 +693,8 @@ final class SybilViewModel {
selectedChat = ChatDetail( selectedChat = ChatDetail(
id: chat.id, id: chat.id,
title: chat.title, title: chat.title,
parentChatId: chat.parentChatId,
titleGenerationPending: chat.titleGenerationPending,
createdAt: chat.createdAt, createdAt: chat.createdAt,
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
starred: chat.starred, starred: chat.starred,
@@ -895,7 +904,8 @@ final class SybilViewModel {
let client = try client() let client = try client()
switch selection { switch selection {
case let .chat(chatID): case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred) let rootChatID = chatFamilyRootID(for: chatID)
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
applyChatSummary(updated, moveToFront: false) applyChatSummary(updated, moveToFront: false)
case let .search(searchID): case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred) let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
@@ -1453,6 +1463,8 @@ final class SybilViewModel {
if selectedChat?.id == chat.id { if selectedChat?.id == chat.id {
selectedChat?.title = chat.title selectedChat?.title = chat.title
selectedChat?.parentChatId = chat.parentChatId
selectedChat?.titleGenerationPending = chat.titleGenerationPending
selectedChat?.updatedAt = chat.updatedAt selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt selectedChat?.starredAt = chat.starredAt
@@ -1504,6 +1516,19 @@ final class SybilViewModel {
workspaceItems.insert(item, at: 0) workspaceItems.insert(item, at: 0)
} }
private func chatFamilyRootID(for chatID: String) -> String {
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
return parentChatID
}
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
return parentChatID
}
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
return chatID
}
private func attachToVisibleActiveRunIfNeeded() { private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else { guard draftKind == nil else {
return return
@@ -1751,13 +1776,16 @@ final class SybilViewModel {
switch target { switch target {
case let .chat(chatID): case let .chat(chatID):
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)") SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
let isSelectingDifferentChat = selectedChat?.id != chatID
let chat = try await client.getChat(chatID: chatID) let chat = try await client.getChat(chatID: chatID)
guard selectedItem == target, draftKind == nil else { guard selectedItem == target, draftKind == nil else {
return return
} }
selectedChat = chat selectedChat = chat
selectedSearch = nil selectedSearch = nil
if isSelectingDifferentChat {
requestChatBottomPin() requestChatBottomPin()
}
if let provider = chat.lastUsedProvider, if let provider = chat.lastUsedProvider,
let model = chat.lastUsedModel, let model = chat.lastUsedModel,
@@ -1850,6 +1878,8 @@ final class SybilViewModel {
selectedChat = ChatDetail( selectedChat = ChatDetail(
id: created.id, id: created.id,
title: created.title, title: created.title,
parentChatId: created.parentChatId,
titleGenerationPending: created.titleGenerationPending,
createdAt: created.createdAt, createdAt: created.createdAt,
updatedAt: created.updatedAt, updatedAt: created.updatedAt,
starred: created.starred, starred: created.starred,
@@ -1908,7 +1938,7 @@ final class SybilViewModel {
let streamLifecycleGeneration = appLifecycleGeneration let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive let streamStartedWhileInactive = !isAppActive
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) { if shouldRequestChatTitle(baseChat) {
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { do {
@@ -2619,20 +2649,10 @@ final class SybilViewModel {
) )
} }
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool { private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
if let detail, detail.id == chatID { if chat.titleGenerationPending {
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
return true return true
} }
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
if let summary = chats.first(where: { $0.id == chatID }) {
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
}
return true
} }
} }
@@ -601,6 +601,7 @@ struct SybilWorkspaceView: View {
.onSubmit { .onSubmit {
submitComposer() submitComposer()
} }
.submitOnMacReturn(submitComposer)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
@@ -1,6 +1,10 @@
import CoreGraphics import CoreGraphics
import Foundation import Foundation
import SwiftUI
import Testing import Testing
#if targetEnvironment(macCatalyst)
import UIKit
#endif
@testable import Sybil @testable import Sybil
private struct MockClientCallSnapshot: Sendable { private struct MockClientCallSnapshot: Sendable {
@@ -10,7 +14,10 @@ private struct MockClientCallSnapshot: Sendable {
var createChat = 0 var createChat = 0
var getChat = 0 var getChat = 0
var updateChatTitle = 0 var updateChatTitle = 0
var suggestChatTitle = 0
var updateChatStar = 0 var updateChatStar = 0
var lastUpdateChatStarID: String?
var lastUpdateChatStarred: Bool?
var updateSearchStar = 0 var updateSearchStar = 0
var getSearch = 0 var getSearch = 0
var getActiveRuns = 0 var getActiveRuns = 0
@@ -36,6 +43,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary] private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary] private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary] private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse private let activeRunsResponse: ActiveRunsResponse
@@ -64,6 +72,7 @@ private actor MockSybilClient: SybilAPIClienting {
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:], updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:], updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:], updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(), activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
@@ -76,6 +85,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse self.activeRunsResponse = activeRunsResponse
@@ -204,6 +214,8 @@ private actor MockSybilClient: SybilAPIClienting {
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary { func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
snapshot.updateChatStar += 1 snapshot.updateChatStar += 1
snapshot.lastUpdateChatStarID = chatID
snapshot.lastUpdateChatStarred = starred
guard let summary = updateChatStarResponses[chatID] else { guard let summary = updateChatStarResponses[chatID] else {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -215,8 +227,12 @@ private actor MockSybilClient: SybilAPIClienting {
} }
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary { func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
snapshot.suggestChatTitle += 1
guard let summary = suggestChatTitleResponses[chatID] else {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
return summary
}
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
@@ -420,6 +436,46 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
) )
} }
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
let decoder = JSONDecoder()
let legacySummary = try decoder.decode(
ChatSummary.self,
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
)
let legacyWorkspaceItem = try decoder.decode(
WorkspaceItem.self,
from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
)
let legacyDetail = try decoder.decode(
ChatDetail.self,
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
)
let forkDetail = try decoder.decode(
ChatDetail.self,
from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
)
#expect(legacySummary.parentChatId == nil)
#expect(!legacySummary.titleGenerationPending)
#expect(legacyWorkspaceItem.parentChatId == nil)
#expect(!legacyWorkspaceItem.titleGenerationPending)
#expect(legacyDetail.parentChatId == nil)
#expect(!legacyDetail.titleGenerationPending)
#expect(forkDetail.parentChatId == "root-chat")
#expect(forkDetail.titleGenerationPending)
var fork = legacySummary
fork.parentChatId = "root-chat"
fork.titleGenerationPending = true
let workspaceItem = WorkspaceItem(chat: fork)
let restoredSummary = try #require(workspaceItem.chatSummary)
#expect(workspaceItem.parentChatId == "root-chat")
#expect(workspaceItem.titleGenerationPending)
#expect(restoredSummary.parentChatId == "root-chat")
#expect(restoredSummary.titleGenerationPending)
}
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws { @Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000) let date = Date(timeIntervalSince1970: 1_700_000_000)
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil) let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
@@ -544,12 +600,14 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
@MainActor @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)
let staleDetail = makeChatDetail(id: "chat-2", date: date, body: "stale transcript")
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript") let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
let client = MockSybilClient(chatDetails: ["chat-2": detail]) let client = MockSybilClient(chatDetails: ["chat-2": detail])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true viewModel.isAuthenticated = true
viewModel.isCheckingSession = false viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-2") viewModel.selectedItem = .chat("chat-2")
viewModel.selectedChat = staleDetail
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
@@ -559,24 +617,22 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1) #expect(snapshot.getChat == 1)
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript") #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
#expect(viewModel.chatBottomPinRequestID == 1) #expect(viewModel.chatBottomPinRequestID == 0)
} }
@MainActor @MainActor
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws { @Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_150) let date = Date(timeIntervalSince1970: 1_700_000_150)
let original = makeChatSummary(id: "chat-rename", date: date) var original = makeChatSummary(id: "chat-rename", date: date)
let renamed = ChatSummary( original.parentChatId = "root-chat"
id: "chat-rename", original.titleGenerationPending = true
title: "Renamed chat", var renamed = original
createdAt: date, renamed.title = "Renamed chat"
updatedAt: date.addingTimeInterval(60), renamed.titleGenerationPending = false
initiatedProvider: .openai, renamed.updatedAt = date.addingTimeInterval(60)
initiatedModel: "gpt-4.1-mini", var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
lastUsedProvider: .openai, detail.parentChatId = original.parentChatId
lastUsedModel: "gpt-4.1-mini" detail.titleGenerationPending = original.titleGenerationPending
)
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
let client = MockSybilClient( let client = MockSybilClient(
chatsResponse: [original], chatsResponse: [original],
updateChatTitleResponses: ["chat-rename": renamed] updateChatTitleResponses: ["chat-rename": renamed]
@@ -594,7 +650,13 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatTitle == 1) #expect(snapshot.updateChatTitle == 1)
#expect(viewModel.sidebarItems.first?.title == "Renamed chat") #expect(viewModel.sidebarItems.first?.title == "Renamed chat")
#expect(viewModel.chats.first?.parentChatId == "root-chat")
#expect(viewModel.chats.first?.titleGenerationPending == false)
#expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
#expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
#expect(viewModel.selectedChat?.title == "Renamed chat") #expect(viewModel.selectedChat?.title == "Renamed chat")
#expect(viewModel.selectedChat?.parentChatId == "root-chat")
#expect(viewModel.selectedChat?.titleGenerationPending == false)
#expect(viewModel.errorMessage == nil) #expect(viewModel.errorMessage == nil)
} }
@@ -633,6 +695,54 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true) #expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
} }
@MainActor
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_180)
var root = makeChatSummary(id: "chat-root", date: date)
root.starred = true
root.starredAt = date.addingTimeInterval(5)
var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
child.parentChatId = root.id
child.titleGenerationPending = true
var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
childDetail.title = child.title
childDetail.parentChatId = root.id
childDetail.titleGenerationPending = true
var unstarredRoot = root
unstarredRoot.starred = false
unstarredRoot.starredAt = nil
let client = MockSybilClient(
chatsResponse: [root, child],
updateChatStarResponses: [root.id: unstarredRoot]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [root, child]
viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
viewModel.selectedItem = .chat(child.id)
viewModel.selectedChat = childDetail
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)
await viewModel.setItemStarred(.chat(child.id), starred: false)
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatStar == 1)
#expect(snapshot.lastUpdateChatStarID == root.id)
#expect(snapshot.lastUpdateChatStarred == false)
#expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
#expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
#expect(viewModel.selectedItem == .chat(child.id))
#expect(viewModel.selectedChat == childDetail)
#expect(viewModel.errorMessage == nil)
}
@MainActor @MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws { @Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200) let date = Date(timeIntervalSince1970: 1_700_000_200)
@@ -675,6 +785,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.displayedMessages.first?.content == "fresh transcript") #expect(viewModel.displayedMessages.first?.content == "fresh transcript")
#expect(!viewModel.isLoadingSelection) #expect(!viewModel.isLoadingSelection)
#expect(viewModel.chatBottomPinRequestID == 1)
} }
@MainActor @MainActor
@@ -778,6 +889,142 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1) #expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
} }
@MainActor
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_247)
var fork = makeChatSummary(id: "chat-fork", date: date)
fork.title = "Fork of Original chat"
fork.parentChatId = "root-chat"
fork.titleGenerationPending = true
var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
forkDetail.title = fork.title
forkDetail.parentChatId = fork.parentChatId
forkDetail.titleGenerationPending = true
var titledFork = fork
titledFork.title = "Investigating the follow-up"
titledFork.titleGenerationPending = false
titledFork.updatedAt = date.addingTimeInterval(1)
var titledForkDetail = forkDetail
titledForkDetail.title = titledFork.title
titledForkDetail.titleGenerationPending = false
titledForkDetail.updatedAt = titledFork.updatedAt
let client = MockSybilClient(
chatsResponse: [titledFork],
chatDetails: [fork.id: titledForkDetail],
suggestChatTitleResponses: [fork.id: titledFork]
)
await client.setCompletionStreamEvents(
[.done(CompletionStreamDone(text: "Follow-up answer"))],
delayNanoseconds: 100_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [fork]
viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
viewModel.selectedItem = .chat(fork.id)
viewModel.selectedChat = forkDetail
viewModel.composer = "Investigate this follow-up"
let sendTask = Task {
await viewModel.sendComposer()
}
for _ in 0..<20 {
let snapshot = await client.currentSnapshot()
if snapshot.suggestChatTitle == 1,
viewModel.selectedChat?.title == titledFork.title,
viewModel.selectedChat?.titleGenerationPending == false {
break
}
try await Task.sleep(nanoseconds: 5_000_000)
}
let titleSnapshot = await client.currentSnapshot()
#expect(titleSnapshot.suggestChatTitle == 1)
#expect(viewModel.chats.first?.title == titledFork.title)
#expect(viewModel.workspaceItems.first?.title == titledFork.title)
#expect(viewModel.selectedChat?.title == titledFork.title)
#expect(viewModel.selectedChat?.titleGenerationPending == false)
await sendTask.value
}
@MainActor
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
var submissions = 0
let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
#expect(initial == .handled)
#expect(repeated == .handled)
#expect(submissions == 1)
}
@MainActor
@Test func composerModifiedReturnPreservesNativeEditing() {
var submissions = 0
for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
#expect(result == .ignored)
}
#expect(submissions == 0)
}
@MainActor
@Test func composerReturnStillSubmitsWithCapsLock() {
var submissions = 0
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
#expect(result == .handled)
#expect(submissions == 1)
}
@Test func quickQuestionHotKeyTogglesOncePerPress() {
var keyState = SybilQuickQuestionHotKeyState()
let keyDownEvents = [true, true, true, false, true, false, false, true]
let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
#expect(toggles == [true, false, false, false, true, false, false, true])
}
@MainActor
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
let client = MockSybilClient()
await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.quickQuestionPrompt = "One question"
let first = viewModel.sendQuickQuestion()
let duplicate = viewModel.sendQuickQuestion()
#expect(first != nil)
#expect(duplicate == nil)
#expect(viewModel.isQuickQuestionSending)
await first?.value
let calls = await client.currentSnapshot()
#expect(calls.runCompletionStream == 1)
#expect(viewModel.quickQuestionAnswerText == "One answer.")
#expect(!viewModel.isQuickQuestionSending)
}
@MainActor
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.quickQuestionPrompt = "Do not send this"
let task = viewModel.sendQuickQuestion()
viewModel.cancelQuickQuestion()
await task?.value
let calls = await client.currentSnapshot()
#expect(calls.runCompletionStream == 0)
#expect(!viewModel.isQuickQuestionSending)
}
@MainActor @MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws { @Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient() let client = MockSybilClient()
@@ -1047,3 +1294,365 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false)) #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true)) #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
} }
#if targetEnvironment(macCatalyst)
@MainActor
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
let commands = SybilMacWindowCommands()
var chats = 0
var windows = 0
#expect(commands.canStartNewChat(hasFocusedActions: false))
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
// A focused value can briefly outlive a closing scene. It must not route
// Command+N back into a closed workspace.
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
#expect(windows == 2)
#expect(chats == 0)
}
@MainActor
@Test func macNewChatCommandUsesExistingWorkspace() {
let commands = SybilMacWindowCommands()
commands.windowConnected(sessionID: "first")
var chats = 0
var windows = 0
#expect(commands.canStartNewChat(hasFocusedActions: true))
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
#expect(chats == 1)
#expect(windows == 0)
}
@MainActor
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
let commands = SybilMacWindowCommands()
commands.windowConnected(sessionID: "authenticating")
var windows = 0
#expect(!commands.canStartNewChat(hasFocusedActions: false))
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
#expect(windows == 0)
}
@MainActor
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
let commands = SybilMacWindowCommands()
commands.windowConnected(sessionID: "first")
commands.windowConnected(sessionID: "first")
commands.windowConnected(sessionID: "second")
commands.windowDisconnected(sessionID: "first")
#expect(commands.hasOpenWindows)
commands.windowDisconnected(sessionID: "first")
#expect(commands.hasOpenWindows)
commands.windowDisconnected(sessionID: "second")
#expect(!commands.hasOpenWindows)
#expect(commands.canStartNewChat(hasFocusedActions: false))
commands.windowConnected(sessionID: "third")
#expect(commands.hasOpenWindows)
#expect(!commands.canStartNewChat(hasFocusedActions: false))
}
@MainActor
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var isVisible = false
var state = SybilQuickQuestionPanelState()
var presentationCount = 0
var activationCount = 0
var stateUpdateCount = 0
var events: [String] = []
var onShow: (() -> Void)?
required override init() { super.init() }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
self.delegate = delegate
return 0
}
func updateState(_ data: Data) {
state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
stateUpdateCount += 1
events.append("update")
}
func show() {
onShow?()
isVisible = true
presentationCount += 1
events.append("show")
}
func hide() {
isVisible = false
delegate?.quickQuestionPanelDismissed()
}
func activateForQuickQuestion() {
activationCount += 1
events.append("activate")
}
func activateMainWindow() {}
func stop() { hide() }
}
@MainActor
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
for _ in 0..<100 {
if condition() { return }
try await Task.sleep(for: .milliseconds(2))
}
try #require(condition())
}
@MainActor
private final class MockQuickQuestionLifecycle {
var isForeground = false
let notifications = NotificationCenter()
func notify(_ name: Notification.Name) {
notifications.post(name: name, object: nil)
}
}
@MainActor
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
let lifecycle = MockQuickQuestionLifecycle()
let panel = MockQuickQuestionPanel()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
viewModel.quickQuestionPrompt = "Original prompt"
let controller = SybilMacQuickQuestionController(
bridge: panel,
isApplicationInForeground: { lifecycle.isForeground },
notificationCenter: lifecycle.notifications
)
defer { controller.stop() }
controller.attach(viewModel)
panel.onShow = { #expect(lifecycle.isForeground) }
controller.toggle()
// Never create/order a window inline in the hotkey's event callback.
#expect(panel.presentationCount == 0)
#expect(panel.activationCount == 0)
try await waitForQuickQuestion { panel.activationCount == 1 }
#expect(panel.stateUpdateCount == 0)
#expect(!panel.isVisible)
// "Will enter" is not proof of readiness: UIKit can still be backgrounded.
lifecycle.notify(UIApplication.willEnterForegroundNotification)
try await Task.sleep(for: .milliseconds(10))
#expect(panel.activationCount == 1)
#expect(panel.presentationCount == 0)
#expect(panel.stateUpdateCount == 0)
viewModel.quickQuestionPrompt = "The latest prompt while waiting"
lifecycle.isForeground = true
lifecycle.notify(UIApplication.didBecomeActiveNotification)
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.events == ["activate", "update", "show"])
#expect(panel.state.prompt == "The latest prompt while waiting")
}
@MainActor
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
let lifecycle = MockQuickQuestionLifecycle()
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(
bridge: panel,
isApplicationInForeground: { lifecycle.isForeground },
notificationCenter: lifecycle.notifications
)
defer { controller.stop() }
controller.toggle()
try await waitForQuickQuestion { panel.activationCount == 1 }
controller.toggle()
lifecycle.isForeground = true
lifecycle.notify(UIApplication.didBecomeActiveNotification)
try await Task.sleep(for: .milliseconds(10))
#expect(!panel.isVisible)
#expect(panel.presentationCount == 0)
#expect(panel.stateUpdateCount == 0)
controller.toggle()
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.presentationCount == 1)
#expect(panel.activationCount == 1)
}
@MainActor
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
let lifecycle = MockQuickQuestionLifecycle()
lifecycle.isForeground = true
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(
bridge: panel,
isApplicationInForeground: { lifecycle.isForeground },
notificationCenter: lifecycle.notifications
)
defer { controller.stop() }
controller.toggle()
controller.toggle()
controller.toggle()
#expect(panel.presentationCount == 0)
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.presentationCount == 1)
#expect(panel.activationCount == 0)
// Lifecycle notifications must not present/select the prompt a second time.
lifecycle.notify(UIApplication.willEnterForegroundNotification)
lifecycle.notify(UIApplication.didBecomeActiveNotification)
try await Task.sleep(for: .milliseconds(10))
#expect(panel.presentationCount == 1)
}
@MainActor
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
let lifecycle = MockQuickQuestionLifecycle()
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(
bridge: panel,
isApplicationInForeground: { lifecycle.isForeground },
notificationCenter: lifecycle.notifications
)
controller.show()
try await waitForQuickQuestion { panel.activationCount == 1 }
controller.stop()
lifecycle.isForeground = true
lifecycle.notify(UIApplication.didBecomeActiveNotification)
try await Task.sleep(for: .milliseconds(10))
#expect(panel.presentationCount == 0)
#expect(panel.stateUpdateCount == 0)
}
@MainActor
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
let lifecycle = MockQuickQuestionLifecycle()
lifecycle.isForeground = true
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(
bridge: panel,
isApplicationInForeground: { lifecycle.isForeground },
notificationCenter: lifecycle.notifications
)
defer { controller.stop() }
controller.show()
lifecycle.isForeground = false
try await waitForQuickQuestion { panel.activationCount == 1 }
#expect(!panel.isVisible)
#expect(panel.stateUpdateCount == 0)
lifecycle.isForeground = true
lifecycle.notify(UIApplication.didBecomeActiveNotification)
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.presentationCount == 1)
}
@MainActor
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionPrompt = "Keep this draft"
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(bridge: panel)
controller.attach(viewModel)
controller.toggle()
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.isVisible)
#expect(panel.state.prompt == "Keep this draft")
#expect(panel.state.canSend)
controller.toggle()
#expect(!panel.isVisible)
viewModel.quickQuestionMessages = [
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
]
controller.toggle()
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.isVisible)
#expect(panel.presentationCount == 2)
#expect(panel.state.prompt == "Keep this draft")
#expect(panel.state.answer == "An answer arrived while hidden.")
let calls = await client.currentSnapshot()
#expect(calls.runCompletionStream == 0)
controller.stop()
}
@MainActor
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionPrompt = "Original window"
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(bridge: panel)
controller.attach(viewModel)
controller.show()
let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
otherWindow.quickQuestionPrompt = "Other window"
controller.attach(otherWindow)
viewModel.quickQuestionMessages = [
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
]
for _ in 0..<30 {
if panel.state.answer == "Streaming update" { break }
try await Task.sleep(for: .milliseconds(5))
}
#expect(panel.state.answer == "Streaming update")
#expect(panel.state.prompt == "Original window")
controller.stop()
}
@MainActor
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
let client = MockSybilClient()
var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
windowViewModel?.isAuthenticated = true
windowViewModel?.isCheckingSession = false
windowViewModel?.quickQuestionPrompt = "A question from the closed window"
windowViewModel?.quickQuestionMessages = [
Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
]
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(bridge: panel)
controller.attach(try #require(windowViewModel))
// Releasing the workspace must not release the global shortcut's model.
windowViewModel = nil
controller.toggle()
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.isVisible)
#expect(panel.state.prompt == "A question from the closed window")
#expect(panel.state.answer == "Keep this answer too.")
controller.toggle()
#expect(!panel.isVisible)
controller.toggle()
try await waitForQuickQuestion { panel.isVisible }
#expect(panel.presentationCount == 2)
#expect(panel.state.canSend)
let calls = await client.currentSnapshot()
#expect(calls.runCompletionStream == 0)
controller.stop()
}
@MainActor
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = false
viewModel.isCheckingSession = false
let panel = MockQuickQuestionPanel()
let controller = SybilMacQuickQuestionController(bridge: panel)
controller.attach(viewModel)
controller.show()
try await waitForQuickQuestion { panel.isVisible }
controller.quickQuestionPromptChanged("A signed-out question")
controller.quickQuestionSubmitRequested()
#expect(!panel.state.canSend)
#expect(panel.state.prompt == "A signed-out question")
#expect(!viewModel.isQuickQuestionSending)
let calls = await client.currentSnapshot()
#expect(calls.runCompletionStream == 0)
controller.stop()
}
#endif
+76
View File
@@ -0,0 +1,76 @@
# 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
@@ -0,0 +1,2 @@
app_identifier("net.buzzert.sybil2")
team_id("DQQH5H6GBD")
+33 -155
View File
@@ -1,169 +1,47 @@
require "shellwords"
default_platform(:ios) default_platform(:ios)
APP_IDENTIFIER = "net.buzzert.sybil2"
SCHEME = "Sybil"
TEAM_ID = "DQQH5H6GBD"
PROFILE_NAME = "Sybil AppStore CI"
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
CI_KEYCHAIN_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}")
CI_KEYCHAIN_DB_PATH = "#{CI_KEYCHAIN_PATH}-db"
IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
def present?(value)
!value.to_s.strip.empty?
end
def ci?
present?(ENV["CI"])
end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
unless match
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
end
match[1]
end
# App Store Connect requires CFBundleVersion to be unique and strictly
# increasing app-wide (not just per marketing version), so we derive it from
# the monotonic CI run number rather than querying TestFlight (that query can
# lag behind builds still processing and hand back a colliding value).
def build_number
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
end
value.to_i
end
def stamp_marketing_version(version)
contents = File.read(APP_PROJECT_SPEC)
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
if updated == contents
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
end
File.write(APP_PROJECT_SPEC, updated)
end
def ci_keychain_path
File.file?(CI_KEYCHAIN_DB_PATH) ? CI_KEYCHAIN_DB_PATH : CI_KEYCHAIN_PATH
end
def signing_xcargs
args = [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
]
if ci?
args << "CODE_SIGN_KEYCHAIN=#{ci_keychain_path.shellescape}"
args << "OTHER_CODE_SIGN_FLAGS=#{("--keychain #{ci_keychain_path}").shellescape}"
end
args.join(" ")
end
platform :ios do platform :ios do
private_lane :app_store_api_key do desc "Build a release tag and upload it to TestFlight"
app_store_connect_api_key( lane :beta do
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"), setup_ci
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"), 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 is_key_content_base64: true
) )
end
# CI uses a dedicated throwaway keychain for match. build_app passes this
# keychain explicitly so codesign does not depend on the runner's ambient
# login/default keychain state.
private_lane :prepare_ci_keychain do
next unless ci?
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH) || File.file?(CI_KEYCHAIN_PATH)
create_keychain(
name: CI_KEYCHAIN_NAME,
password: CI_KEYCHAIN_PASSWORD,
unlock: true,
timeout: 3600,
add_to_search_list: true
)
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
end
private_lane :sync_signing do |options|
match(
type: "appstore",
readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER,
team_id: TEAM_ID,
profile_name: PROFILE_NAME,
git_url: ENV.fetch("MATCH_GIT_URL"),
git_branch: "master",
git_full_name: "Sybil Release Bot",
git_user_email: "james.magahern@me.com",
api_key: options.fetch(:api_key)
)
end
desc "Create or update match signing assets"
lane :setup_signing do
sync_signing(api_key: app_store_api_key, readonly: false)
end
desc "Build and upload to TestFlight"
lane :beta do
prepare_ci_keychain
api_key = app_store_api_key
version = release_version
stamp_marketing_version(version)
sh("xcodegen", "--spec", PROJECT_SPEC)
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
sync_signing(api_key: api_key, readonly: true)
build_app( build_app(
project: PROJECT_FILE, project: File.join(ios_dir, "Sybil.xcodeproj"),
scheme: SCHEME, scheme: "Sybil"
export_method: "app-store",
codesigning_identity: "Apple Distribution",
xcargs: signing_xcargs,
export_options: {
signingStyle: "manual",
teamID: TEAM_ID,
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
) )
upload_to_testflight( upload_to_testflight(
api_key: api_key, api_key: api_key,
skip_waiting_for_build_processing: true skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
) )
end end
end end
+7
View File
@@ -0,0 +1,7 @@
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")
+6
View File
@@ -24,6 +24,12 @@ run: generate
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app' xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2 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: beta:
fastlane ios beta fastlane ios beta
+1
View File
@@ -7,3 +7,4 @@ packages:
path: Packages/Sybil path: Packages/Sybil
include: include:
- Apps/Sybil/project.yml - Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
+90
View File
@@ -0,0 +1,90 @@
#!/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
@@ -0,0 +1,357 @@
#!/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"
+6 -4
View File
@@ -1,7 +1,7 @@
# Sybil Server # Sybil Server
Backend API for: Backend API for:
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent) - LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
- Personal chat database (chats/messages + LLM call log) - Personal chat database (chats/messages + LLM call log)
## Stack ## Stack
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY` - `ANTHROPIC_API_KEY`
- `XAI_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_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_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`) - `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
- `EXA_API_KEY` - `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only) - `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`) - `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_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI) - `CHAT_CODEX_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_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_USER` (optional SSH user when host does not include one)
- `CHAT_CODEX_REMOTE_PORT` (`22` by default) - `CHAT_CODEX_REMOTE_PORT` (`22` by default)
@@ -58,7 +60,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key) - `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_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default) - `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox) - `CHAT_SHELL_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) - `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
## API ## API
@@ -0,0 +1,5 @@
-- 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");
+8
View File
@@ -13,6 +13,7 @@ enum Provider {
openai openai
anthropic anthropic
xai xai
gemini
hermes_agent @map("hermes-agent") hermes_agent @map("hermes-agent")
} }
@@ -51,6 +52,7 @@ model Chat {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
title String? title String?
titleGenerationPending Boolean @default(false)
initiatedProvider Provider? initiatedProvider Provider?
initiatedModel String? initiatedModel String?
@@ -63,11 +65,17 @@ model Chat {
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? 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[] messages Message[]
calls LlmCall[] calls LlmCall[]
projectItems ProjectItem[] projectItems ProjectItem[]
@@index([userId]) @@index([userId])
@@index([parentChatId])
} }
model Message { model Message {
+11 -1
View File
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined; return trimmed ? trimmed.toLowerCase() : undefined;
}, },
z.enum(["exa", "searxng"]).default("exa") z.enum(["exa", "searxng", "brave"]).default("exa")
); );
const BooleanFlagSchema = z.preprocess((value) => { const BooleanFlagSchema = z.preprocess((value) => {
@@ -66,10 +66,12 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(),
XAI_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_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema, HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema, HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(), 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-mode web_search tool configuration. Search mode remains Exa-only for now.
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema, CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
@@ -99,6 +101,14 @@ const EnvSchema = z.object({
}); });
} }
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) { if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
+23
View File
@@ -7,6 +7,7 @@ import { convert as htmlToText } from "html-to-text";
import { z } from "zod"; import { z } from "zod";
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js"; import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { searchBrave } from "../search/brave.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
@@ -507,11 +508,33 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
}; };
} }
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> { async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input); const args = WebSearchArgsSchema.parse(input);
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") { if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
return runSearxngWebSearchTool(args); return runSearxngWebSearchTool(args);
} }
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
return runBraveWebSearchTool(args);
}
return runExaWebSearchTool(args); return runExaWebSearchTool(args);
} }
+501
View File
@@ -0,0 +1,501 @@
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,
},
};
}
+70 -3
View File
@@ -5,10 +5,11 @@ import {
type ToolAwareStreamingEvent, type ToolAwareStreamingEvent,
} from "./chat-tools.js"; } from "./chat-tools.js";
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.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 { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js"; import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
import type { ChatMessage, Provider } from "./types.js"; import type { ChatMessage, Provider } from "./types.js";
type ProviderAdapterParams = { type ProviderAdapterParams = {
@@ -27,7 +28,7 @@ export type ProviderChatAdapter = {
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>; stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
}; };
type ChatProtocolId = "chat-completions" | "messages" | "responses"; type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
type ChatProtocol = { type ChatProtocol = {
id: ChatProtocolId; id: ChatProtocolId;
@@ -39,6 +40,7 @@ type ModelCatalogSpec = {
enabled?: () => boolean; enabled?: () => boolean;
fetchModels(client: any): Promise<string[]>; fetchModels(client: any): Promise<string[]>;
fallbackModels?: () => string[]; fallbackModels?: () => string[];
sortModels?: (models: string[]) => string[];
}; };
type ProviderBackendSpec = { type ProviderBackendSpec = {
@@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = {
stream: streamWithMessagesApi, stream: streamWithMessagesApi,
}; };
const geminiProtocol: ChatProtocol = {
id: "gemini",
complete: completeWithGeminiApi,
stream: streamWithGeminiApi,
};
const responsesProtocol: ChatProtocol = { const responsesProtocol: ChatProtocol = {
id: "responses", id: "responses",
complete: completeWithResponsesApi, complete: completeWithResponsesApi,
@@ -77,6 +85,10 @@ function modelIdsFromListResponse(page: any) {
: []; : [];
} }
function stripModelResourcePrefix(model: string) {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}
function isLikelyResponsesApiModel(model: string) { function isLikelyResponsesApiModel(model: string) {
const id = model.toLowerCase(); const id = model.toLowerCase();
if (id.includes("embedding") || id.includes("moderation")) return false; if (id.includes("embedding") || id.includes("moderation")) return false;
@@ -86,6 +98,37 @@ function isLikelyResponsesApiModel(model: string) {
return /^(gpt-|o\d|chatgpt-)/.test(id); 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 { function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
return { return {
client, client,
@@ -160,6 +203,29 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
}, },
}, },
}, },
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": { "hermes-agent": {
createClient: hermesAgentClient, createClient: hermesAgentClient,
plainProtocol: chatCompletionsProtocol, plainProtocol: chatCompletionsProtocol,
@@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] {
export async function fetchProviderCatalogModels(provider: Provider) { export async function fetchProviderCatalogModels(provider: Provider) {
const spec = backendSpecs[provider].modelCatalog; const spec = backendSpecs[provider].modelCatalog;
if (!spec) return []; if (!spec) return [];
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient())); const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
return spec.sortModels ? spec.sortModels(models) : models;
} }
export function getProviderCatalogFallbackModels(provider: Provider) { export function getProviderCatalogFallbackModels(provider: Provider) {
+2
View File
@@ -6,6 +6,7 @@ const apiToPrismaProvider = {
openai: "openai", openai: "openai",
anthropic: "anthropic", anthropic: "anthropic",
xai: "xai", xai: "xai",
gemini: "gemini",
"hermes-agent": "hermes_agent", "hermes-agent": "hermes_agent",
} as const satisfies Record<Provider, PrismaProvider>; } as const satisfies Record<Provider, PrismaProvider>;
@@ -13,6 +14,7 @@ const prismaToApiProvider = {
openai: "openai", openai: "openai",
anthropic: "anthropic", anthropic: "anthropic",
xai: "xai", xai: "xai",
gemini: "gemini",
hermes_agent: "hermes-agent", hermes_agent: "hermes-agent",
"hermes-agent": "hermes-agent", "hermes-agent": "hermes-agent",
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>; } as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
+9 -1
View File
@@ -1,5 +1,5 @@
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { env } from "../env.js"; import { env } from "../env.js";
export function openaiClient() { export function openaiClient() {
@@ -13,6 +13,14 @@ export function xaiClient() {
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" }); 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() { export function isHermesAgentConfigured() {
return Boolean(env.HERMES_AGENT_API_KEY); return Boolean(env.HERMES_AGENT_API_KEY);
} }
+6 -1
View File
@@ -119,7 +119,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
if (shouldPersist && chatId && call) { if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.message.create({ await tx.message.create({
data: { chatId, role: "assistant" as any, content: text }, data: {
chatId,
role: "assistant" as any,
content: text,
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
},
}); });
await tx.llmCall.update({ await tx.llmCall.update({
where: { id: call.id }, where: { id: call.id },
+2 -1
View File
@@ -1,4 +1,4 @@
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const; export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
export type Provider = (typeof PROVIDERS)[number]; export type Provider = (typeof PROVIDERS)[number];
@@ -33,6 +33,7 @@ export type ChatMessage = {
export type MultiplexRequest = { export type MultiplexRequest = {
chatId?: string; chatId?: string;
persist?: boolean; persist?: boolean;
clientRequestId?: string;
provider: Provider; provider: Provider;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
+289 -36
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { z } from "zod"; import { z } from "zod";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -16,8 +17,9 @@ import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import type { ChatAttachment } from "./llm/types.js"; import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]); const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000; const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value)); const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
type IncomingChatMessage = { type IncomingChatMessage = {
@@ -88,13 +90,13 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) }; return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
} }
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) { async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
const incoming = messages.filter((m) => m.role !== "assistant"); const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return; if (!incoming.length) return;
const existing = await prisma.message.findMany({ const existing = await prisma.message.findMany({
where: { chatId }, where: { chatId },
orderBy: { createdAt: "asc" }, orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { role: true, content: true, name: true, metadata: true }, select: { role: true, content: true, name: true, metadata: true },
}); });
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m)); const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
@@ -109,14 +111,21 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming; const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
if (!toInsert.length) return; if (!toInsert.length) return;
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
await prisma.message.createMany({ await prisma.message.createMany({
data: toInsert.map((m) => ({ data: toInsert.map((m, index) => {
const metadata = {
...(m.attachments?.length ? { attachments: m.attachments } : {}),
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
};
return {
chatId, chatId,
role: m.role as any, role: m.role as any,
content: m.content, content: m.content,
name: m.name, name: m.name,
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined, metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
})), };
}),
}); });
} }
@@ -169,6 +178,7 @@ const CompletionStreamBody = z
.object({ .object({
chatId: z.string().optional(), chatId: z.string().optional(),
persist: z.boolean().optional(), persist: z.boolean().optional(),
clientRequestId: z.string().trim().min(1).max(128).optional(),
provider: ProviderSchema, provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), messages: z.array(CompletionMessageSchema),
@@ -186,6 +196,13 @@ const CompletionStreamBody = z
path: ["chatId"], path: ["chatId"],
}); });
} }
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "clientRequestId requires a persisted stream with chatId",
path: ["clientRequestId"],
});
}
}); });
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) { function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
@@ -302,6 +319,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback; return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
} }
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
if (messageContent !== undefined) {
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
return `Fork of '${snippet}'`;
}
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
}
export function copyForkMessageMetadata(metadata: unknown) {
if (metadata === null || metadata === undefined) return undefined;
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
return Object.keys(copied).length ? copied : undefined;
}
async function generateChatTitle(content: string) { async function generateChatTitle(content: string) {
const systemPrompt = const systemPrompt =
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation."; "You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
@@ -399,6 +431,8 @@ function buildSseHeaders(originHeader: string | undefined) {
type SearchRunRequest = z.infer<typeof SearchRunBody>; type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>(); const activeChatStreams = new Map<string, ActiveSseStream>();
const activeChatStreamRequestIds = new Map<string, string>();
const chatDeletionRoots = new Set<string>();
const activeSearchStreams = new Map<string, ActiveSseStream>(); const activeSearchStreams = new Map<string, ActiveSseStream>();
const STARRED_PROJECT_ID = "starred"; const STARRED_PROJECT_ID = "starred";
@@ -411,6 +445,8 @@ const starredProjectItemsSelect = {
const chatSummarySelect = { const chatSummarySelect = {
id: true, id: true,
title: true, title: true,
titleGenerationPending: true,
parentChatId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
initiatedProvider: true, initiatedProvider: true,
@@ -491,6 +527,29 @@ async function getSearchSummary(searchId: string) {
return search ? serializeSearchLike(search) : null; return search ? serializeSearchLike(search) : null;
} }
async function listRecentChatsWithRoots() {
const chats = await prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
});
const includedIds = new Set(chats.map((chat) => chat.id));
const missingRootIds = [
...new Set(
chats
.map((chat) => chat.parentChatId)
.filter((id): id is string => id !== null && !includedIds.has(id))
),
];
if (!missingRootIds.length) return chats;
const roots = await prisma.chat.findMany({
where: { id: { in: missingRootIds } },
select: chatSummarySelect,
});
return [...chats, ...roots].sort(compareUpdatedAtDesc);
}
async function setChatStarred(chatId: string, starred: boolean) { async function setChatStarred(chatId: string, starred: boolean) {
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } }); const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
if (!exists) return null; if (!exists) return null;
@@ -529,11 +588,7 @@ async function setSearchStarred(searchId: string, starred: boolean) {
async function listWorkspaceItems() { async function listWorkspaceItems() {
const [chats, searches] = await Promise.all([ const [chats, searches] = await Promise.all([
prisma.chat.findMany({ listRecentChatsWithRoots(),
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
}),
prisma.search.findMany({ prisma.search.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 100, take: 100,
@@ -554,6 +609,7 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
} }
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) { async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
if (reply.raw.destroyed || reply.raw.writableEnded) return reply;
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders?.(); reply.raw.flushHeaders?.();
@@ -588,10 +644,24 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
return { event: ev.type, data: ev }; return { event: ev.type, data: ev };
} }
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) { export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
const stream = new ActiveSseStream(); const stream = new ActiveSseStream();
activeChatStreams.set(chatId, stream); activeChatStreams.set(chatId, stream);
if (clientRequestId) {
activeChatStreamRequestIds.set(chatId, clientRequestId);
} else {
activeChatStreamRequestIds.delete(chatId);
}
return stream;
}
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
if (activeChatStreams.get(chatId) !== stream) return;
activeChatStreams.delete(chatId);
activeChatStreamRequestIds.delete(chatId);
}
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
void (async () => { void (async () => {
let sawTerminalEvent = false; let sawTerminalEvent = false;
try { try {
@@ -611,11 +681,46 @@ function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionSt
} catch (err) { } catch (err) {
stream.complete({ event: "error", data: { message: getErrorMessage(err) } }); stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
} finally { } finally {
activeChatStreams.delete(chatId); clearActiveChatStream(chatId, stream);
} }
})(); })();
}
return stream; function getMetadataClientRequestId(metadata: unknown) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
return typeof clientRequestId === "string" ? clientRequestId : null;
}
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
const assistantMessages = await prisma.message.findMany({
where: { chatId, role: "assistant" as any },
orderBy: { createdAt: "desc" },
select: { content: true, metadata: true },
});
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
}
function completeChatSubmissionStream(
stream: ActiveSseStream,
chatId: string,
body: z.infer<typeof CompletionStreamBody>,
assistantText: string
) {
stream.emit("meta", {
type: "meta",
chatId,
callId: null,
provider: body.provider,
model: body.model,
});
stream.complete({
event: "done",
data: {
type: "done",
text: assistantText,
},
});
} }
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) { async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
@@ -816,11 +921,7 @@ export async function registerRoutes(app: FastifyInstance) {
app.get("/v1/chats", async (req) => { app.get("/v1/chats", async (req) => {
requireAdmin(req); requireAdmin(req);
const chats = await prisma.chat.findMany({ const chats = await listRecentChatsWithRoots();
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
});
return { chats: chats.map((chat) => serializeChatLike(chat)) }; return { chats: chats.map((chat) => serializeChatLike(chat)) };
}); });
@@ -879,6 +980,80 @@ export async function registerRoutes(app: FastifyInstance) {
return { chat: serializeChatLike(chat) }; return { chat: serializeChatLike(chat) };
}); });
app.post("/v1/chats/:chatId/fork", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
const { chatId } = Params.parse(req.params);
const parsed = Body.safeParse(req.body ?? {});
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const result = await prisma.$transaction(async (tx) => {
const source = await tx.chat.findUnique({
where: { id: chatId },
select: {
id: true,
title: true,
parentChatId: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
userId: true,
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
},
});
if (!source) return { status: "chat-not-found" as const };
let messages = source.messages;
let selectedMessage: (typeof source.messages)[number] | undefined;
if (parsed.data.messageId) {
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
if (selectedIndex < 0) return { status: "message-not-found" as const };
selectedMessage = messages[selectedIndex];
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
messages = messages.slice(0, selectedIndex + 1);
}
const forkMessageIdPrefix = `fork-${randomUUID()}`;
const chat = await tx.chat.create({
data: {
title: buildForkTitle(source.title, selectedMessage?.content),
titleGenerationPending: true,
parentChatId: source.parentChatId ?? source.id,
initiatedProvider: source.initiatedProvider,
initiatedModel: source.initiatedModel,
lastUsedProvider: source.lastUsedProvider,
lastUsedModel: source.lastUsedModel,
additionalSystemPrompt: source.additionalSystemPrompt,
enabledTools: (source.enabledTools ?? undefined) as any,
userId: source.userId,
messages: messages.length
? {
create: messages.map((message, index) => ({
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
createdAt: message.createdAt,
role: message.role,
content: message.content,
name: message.name,
metadata: copyForkMessageMetadata(message.metadata) as any,
})),
}
: undefined,
},
select: chatSummarySelect,
});
return { status: "created" as const, chat };
});
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
return { chat: serializeChatLike(result.chat) };
});
app.patch("/v1/chats/:chatId", async (req) => { app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ chatId: z.string() }); const Params = z.object({ chatId: z.string() });
@@ -891,7 +1066,10 @@ export async function registerRoutes(app: FastifyInstance) {
const body = Body.parse(req.body ?? {}); const body = Body.parse(req.body ?? {});
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (body.title !== undefined) data.title = body.title; if (body.title !== undefined) {
data.title = body.title;
data.titleGenerationPending = false;
}
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt); if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools; if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
@@ -932,15 +1110,30 @@ export async function registerRoutes(app: FastifyInstance) {
select: chatSummarySelect, select: chatSummarySelect,
}); });
if (!existing) return app.httpErrors.notFound("chat not found"); if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: serializeChatLike(existing) }; if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat"; const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content); let suggestedRaw = "";
try {
suggestedRaw = await generateChatTitle(body.content);
} catch (err) {
req.log.warn(
{
chatId: body.chatId,
err: getErrorMessage(err),
},
"chat title generation failed; using fallback"
);
}
const title = normalizeSuggestedTitle(suggestedRaw, fallback); const title = normalizeSuggestedTitle(suggestedRaw, fallback);
await prisma.chat.updateMany({ await prisma.chat.updateMany({
where: { id: body.chatId, title: existing.title }, where: {
data: { title }, id: body.chatId,
title: existing.title,
titleGenerationPending: existing.titleGenerationPending,
},
data: { title, titleGenerationPending: false },
}); });
const chat = await getChatSummary(body.chatId); const chat = await getChatSummary(body.chatId);
@@ -956,14 +1149,46 @@ export async function registerRoutes(app: FastifyInstance) {
req.log.info({ chatId }, "delete chat requested"); req.log.info({ chatId }, "delete chat requested");
const target = await prisma.chat.findUnique({
where: { id: chatId },
select: { parentChatId: true },
});
if (!target) {
req.log.warn({ chatId }, "delete chat target not found");
return app.httpErrors.notFound("chat not found");
}
const familyRootId = target.parentChatId ?? chatId;
if (chatDeletionRoots.has(familyRootId)) {
return app.httpErrors.conflict("chat family deletion already in progress");
}
chatDeletionRoots.add(familyRootId);
try {
const familyIds = target.parentChatId
? [chatId]
: (
await prisma.chat.findMany({
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
select: { id: true },
})
).map((chat) => chat.id);
if (familyIds.some((id) => activeChatStreams.has(id))) {
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
return app.httpErrors.conflict("chat or fork has an active stream");
}
const result = await prisma.chat.deleteMany({ where: { id: chatId } }); const result = await prisma.chat.deleteMany({ where: { id: chatId } });
if (result.count === 0) { if (result.count === 0) {
req.log.warn({ chatId }, "delete chat target not found"); req.log.warn({ chatId }, "delete chat target no longer exists");
return app.httpErrors.notFound("chat not found"); return app.httpErrors.notFound("chat not found");
} }
req.log.info({ chatId }, "chat deleted"); req.log.info({ chatId }, "chat deleted");
return { deleted: true }; return { deleted: true };
} finally {
chatDeletionRoots.delete(familyRootId);
}
}); });
app.get("/v1/searches", async (req) => { app.get("/v1/searches", async (req) => {
@@ -1253,7 +1478,7 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { id: chatId }, where: { id: chatId },
include: { include: {
messages: { orderBy: { createdAt: "asc" } }, messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
calls: { orderBy: { createdAt: "desc" } }, calls: { orderBy: { createdAt: "desc" } },
projectItems: starredProjectItemsSelect, projectItems: starredProjectItemsSelect,
}, },
@@ -1347,23 +1572,51 @@ export async function registerRoutes(app: FastifyInstance) {
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = withRequestUserLocation(parsed.data, req); const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // Ensure the chat exists and identify its family before reserving a stream.
let chatFamilyRootId: string | null = null;
if (body.chatId) { if (body.chatId) {
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } }); const exists = await prisma.chat.findUnique({
where: { id: body.chatId },
select: { id: true, parentChatId: true },
});
if (!exists) return app.httpErrors.notFound("chat not found"); if (!exists) return app.httpErrors.notFound("chat not found");
} chatFamilyRootId = exists.parentChatId ?? exists.id;
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.persist !== false && body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
} }
if (body.persist !== false && body.chatId) { if (body.persist !== false && body.chatId) {
if (activeChatStreams.has(body.chatId)) { const activeStream = activeChatStreams.get(body.chatId);
if (activeStream) {
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
return streamActiveRun(req, reply, activeStream);
}
return app.httpErrors.conflict("chat completion already running"); return app.httpErrors.conflict("chat completion already running");
} }
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
return streamActiveRun(req, reply, stream); if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
return app.httpErrors.conflict("chat family deletion already in progress");
}
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
try {
if (body.clientRequestId) {
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
if (completedSubmission) {
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
clearActiveChatStream(body.chatId, reservedStream);
return streamActiveRun(req, reply, reservedStream);
}
}
// Reserve the stream before persistence so deletion cannot interleave with setup.
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
const configuredBody = await applyStoredChatSettings(body);
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
return streamActiveRun(req, reply, reservedStream);
} catch (err) {
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
clearActiveChatStream(body.chatId, reservedStream);
throw err;
}
} }
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
+305
View File
@@ -0,0 +1,305 @@
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();
}
+284
View File
@@ -0,0 +1,284 @@
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
@@ -0,0 +1,252 @@
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);
});
+6
View File
@@ -27,6 +27,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
managedTools: true, managedTools: true,
enabledTools: ["web_search"], 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"]), { assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
provider: "hermes-agent", provider: "hermes-agent",
protocol: "chat-completions", protocol: "chat-completions",
+4 -2
View File
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
test("Hermes Agent provider id maps between API and Prisma enum forms", () => { test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent"); assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent"); assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), { assert.equal(toPrismaProvider("gemini"), "gemini");
assert.equal(fromPrismaProvider("gemini"), "gemini");
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
initiatedProvider: "hermes-agent", initiatedProvider: "hermes-agent",
lastUsedProvider: "xai", lastUsedProvider: "gemini",
}); });
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Provider } from "./types.js"; import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"]; const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
function normalizeBaseUrl(value: string) { function normalizeBaseUrl(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
+36 -13
View File
@@ -42,12 +42,13 @@ type ToolLogMetadata = {
resultPreview?: string | null; resultPreview?: string | null;
}; };
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
"hermes-agent": ["hermes-agent"], "hermes-agent": ["hermes-agent"],
}; };
@@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
openai: { models: [], loadedAt: null, error: null }, openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null }, anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null },
gemini: { models: [], loadedAt: null, error: null },
}; };
function escapeTags(value: string) { function escapeTags(value: string) {
@@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI"; if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic"; if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI"; if (provider === "xai") return "xAI";
if (provider === "gemini") return "Gemini";
if (provider === "hermes-agent") return "Hermes Agent"; if (provider === "hermes-agent") return "Hermes Agent";
return ""; return "";
} }
@@ -124,17 +127,23 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
} }
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { 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) => { return items.map((item) => {
if (item.type === "chat") { if (item.type === "chat") {
const chat = item; const chat = item;
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
return { return {
kind: "chat" as const, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
createdAt: chat.createdAt, createdAt: chat.createdAt,
starred: chat.starred, starred: starOwner.starred,
starredAt: chat.starredAt, starredAt: starOwner.starredAt,
initiatedProvider: chat.initiatedProvider, initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
@@ -266,6 +275,7 @@ async function main() {
openai: null, openai: null,
anthropic: null, anthropic: null,
xai: null, xai: null,
gemini: null,
"hermes-agent": null, "hermes-agent": null,
}; };
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
@@ -975,11 +985,11 @@ async function main() {
focusComposer(); focusComposer();
} }
async function maybeSuggestTitle(chatId: string, content: string) { async function maybeSuggestTitle(chat: ChatDetail, content: string) {
const chatSummary = chats.find((chat) => chat.id === chatId); const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
const chatId = chat.id;
pendingTitleGeneration.add(chatId); pendingTitleGeneration.add(chatId);
try { try {
const updated = await api.suggestChatTitle({ chatId, content }); const updated = await api.suggestChatTitle({ chatId, content });
@@ -989,6 +999,8 @@ async function main() {
selectedChat = { selectedChat = {
...selectedChat, ...selectedChat,
title: updated.title, title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
starred: updated.starred, starred: updated.starred,
starredAt: updated.starredAt, starredAt: updated.starredAt,
@@ -1045,6 +1057,8 @@ async function main() {
selectedChat = { selectedChat = {
id: chat.id, id: chat.id,
title: chat.title, title: chat.title,
parentChatId: chat.parentChatId,
titleGenerationPending: chat.titleGenerationPending,
createdAt: chat.createdAt, createdAt: chat.createdAt,
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
starred: chat.starred, starred: chat.starred,
@@ -1064,13 +1078,13 @@ async function main() {
throw new Error("Unable to initialize chat"); throw new Error("Unable to initialize chat");
} }
void maybeSuggestTitle(chatId, content);
let baseChat = selectedChat; let baseChat = selectedChat;
if (!baseChat || baseChat.id !== chatId) { if (!baseChat || baseChat.id !== chatId) {
baseChat = await api.getChat(chatId); baseChat = await api.getChat(chatId);
} }
void maybeSuggestTitle(baseChat, content);
const requestMessages: CompletionRequestMessage[] = [ const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages ...baseChat.messages
.filter((message) => !isToolCallLogMessage(message)) .filter((message) => !isToolCallLogMessage(message))
@@ -1388,6 +1402,8 @@ async function main() {
selectedChat = { selectedChat = {
...selectedChat, ...selectedChat,
title: updated.title, title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
initiatedProvider: updated.initiatedProvider, initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel, initiatedModel: updated.initiatedModel,
@@ -1401,12 +1417,16 @@ async function main() {
async function handleToggleStarSelection() { async function handleToggleStarSelection() {
if (!selectedItem) return; if (!selectedItem) return;
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
const nextStarred = !currentItem?.starred;
setError(null); setError(null);
if (selectedItem.kind === "chat") { if (selectedItem.kind === "chat") {
const updated = await api.updateChatStar(selectedItem.id, nextStarred); 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)); chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats]; 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)); workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
@@ -1417,6 +1437,8 @@ async function main() {
selectedChat = { selectedChat = {
...selectedChat, ...selectedChat,
title: updated.title, title: updated.title,
parentChatId: updated.parentChatId,
titleGenerationPending: updated.titleGenerationPending,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
starred: updated.starred, starred: updated.starred,
starredAt: updated.starredAt, starredAt: updated.starredAt,
@@ -1427,7 +1449,8 @@ async function main() {
}; };
} }
} else { } else {
const updated = await api.updateSearchStar(selectedItem.id, nextStarred); 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)); searches = searches.map((search) => (search.id === updated.id ? updated : search));
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches]; 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)); workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
+5 -1
View File
@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent"; export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -13,6 +13,8 @@ export type ModelCatalogResponse = {
export type ChatSummary = { export type ChatSummary = {
id: string; id: string;
title: string | null; title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean; starred: boolean;
@@ -68,6 +70,8 @@ export type ToolCallEvent = {
export type ChatDetail = { export type ChatDetail = {
id: string; id: string;
title: string | null; title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean; starred: boolean;
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test --experimental-strip-types tests/*.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+16 -2
View File
@@ -3,10 +3,24 @@ self.addEventListener("install", () => {
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(
(async () => {
await self.clients.claim();
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
await Promise.all(
windows.map(async (client) => {
try {
await client.navigate(client.url);
} catch {
// The client may have closed while the new worker was activating.
}
})
);
})()
);
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return; if (event.request.mode !== "navigate") return;
event.respondWith(fetch(event.request)); event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
}); });
+840 -197
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type MutableValueRef = {
current: string;
};
type Props = {
draftRef: MutableValueRef;
draftRevision: number;
error: string | null;
isSearchMode: boolean;
isSending: boolean;
pendingAttachmentCount: number;
attachmentButtonDisabled: boolean;
onOpenAttachmentPicker: () => void;
onPaste: (event: ClipboardEvent) => void;
onSend: (draft: string) => void | Promise<void>;
};
const MIRROR_SENTINEL = "\u200b";
const HAS_NON_WHITESPACE = /\S/;
export function ChatComposer({
draftRef,
draftRevision,
error,
isSearchMode,
isSending,
pendingAttachmentCount,
attachmentButtonDisabled,
onOpenAttachmentPicker,
onPaste,
onSend,
}: Props) {
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
const textareaContainerRef = useRef<HTMLDivElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
const hasDraftRef = useRef(hasDraft);
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
const updateMirror = (value: string) => {
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
};
const updateHasDraft = (value: string) => {
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
if (nextHasDraft !== hasDraftRef.current) {
hasDraftRef.current = nextHasDraft;
setHasDraft(nextHasDraft);
}
};
useLayoutEffect(() => {
const value = draftRef.current;
const textarea = getTextarea();
if (textarea && textarea.value !== value) {
textarea.value = value;
}
updateMirror(value);
updateHasDraft(value);
}, [draftRevision]);
const submit = () => {
const textarea = getTextarea();
const draft = textarea?.value ?? draftRef.current;
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
if (isSending || !canSend) return;
draftRef.current = "";
if (textarea) textarea.value = "";
updateMirror("");
updateHasDraft("");
void onSend(draft);
};
return (
<>
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
<div
ref={mirrorRef}
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
aria-hidden="true"
/>
<Textarea
id="composer-input"
rows={1}
onInput={(event) => {
const value = event.currentTarget.value;
draftRef.current = value;
updateMirror(value);
updateHasDraft(value);
}}
onPaste={(event) => {
onPaste(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
}}
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
disabled={isSending}
/>
</div>
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
{!isSearchMode ? (
<Button
className="h-10 w-10 rounded-lg"
onClick={onOpenAttachmentPicker}
size="icon"
variant="secondary"
disabled={attachmentButtonDisabled}
aria-label="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
) : null}
<Button
className="h-10 w-10 rounded-lg"
onClick={submit}
size="icon"
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
aria-label={isSearchMode ? "Search" : "Send message"}
>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button>
</div>
</>
);
}
@@ -10,6 +10,7 @@ type Props = {
messages: Message[]; messages: Message[];
isLoading: boolean; isLoading: boolean;
isSending: boolean; isSending: boolean;
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
}; };
type ToolLogMetadata = { type ToolLogMetadata = {
@@ -395,7 +396,7 @@ function ToolCallStack({
); );
} }
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]); const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]); const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
@@ -467,6 +468,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm" ? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50" : "text-base leading-7 text-violet-50"
)} )}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
? (event) => onMessageContextMenu(event, message.id)
: undefined
}
> >
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null} {attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{isPendingAssistant ? ( {isPendingAssistant ? (
@@ -478,6 +484,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
) : message.content.trim() ? ( ) : message.content.trim() ? (
<MarkdownContent <MarkdownContent
markdown={message.content} markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")} className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
/> />
) : null} ) : null}
@@ -10,6 +10,7 @@ type Props = {
className?: string; className?: string;
mode?: MarkdownMode; mode?: MarkdownMode;
resolveCitationIndex?: (href: string) => number | undefined; resolveCitationIndex?: (href: string) => number | undefined;
openLinksInNewTab?: boolean;
}; };
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) { function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
@@ -28,17 +29,30 @@ markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`; return `<div class="md-table-scroll">${renderTable(token)}</div>`;
}; };
function renderMarkdown(markdown: string) { function setNewTabLinkAttributes(currentNode: Element) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string; if (currentNode.tagName !== "A") return;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); currentNode.setAttribute("target", "_blank");
currentNode.setAttribute("rel", "noopener noreferrer");
} }
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) { function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
try {
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
} finally {
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
}
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
const html = useMemo(() => { const html = useMemo(() => {
const prepared = const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown; mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
return renderMarkdown(prepared); return renderMarkdown(prepared, openLinksInNewTab);
}, [markdown, mode, resolveCitationIndex]); }, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />; return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
} }
+39
View File
@@ -131,6 +131,45 @@ textarea {
} }
} }
@media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell {
display: grid;
grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
}
.workspace-sidebar {
position: static;
width: 100%;
max-width: none;
transform: none;
border-width: 1px;
border-radius: 1rem;
}
.workspace-content {
min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto;
}
.workspace-sidebar-backdrop,
.workspace-sidebar-trigger {
display: none;
}
}
.glass-panel { .glass-panel {
background: background:
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)), linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
+26 -151
View File
@@ -1,6 +1,8 @@
export type ChatSummary = { export type ChatSummary = {
id: string; id: string;
title: string | null; title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean; starred: boolean;
@@ -58,6 +60,8 @@ export type ToolCallEvent = {
export type ChatDetail = { export type ChatDetail = {
id: string; id: string;
title: string | null; title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean; starred: boolean;
@@ -149,7 +153,7 @@ export type CompletionRequestMessage = {
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
}; };
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent"; export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -291,6 +295,14 @@ export async function getChat(chatId: string) {
return data.chat; return data.chat;
} }
export async function forkChat(chatId: string, messageId?: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
method: "POST",
body: JSON.stringify(messageId ? { messageId } : {}),
});
return data.chat;
}
export async function updateChatTitle(chatId: string, title: string) { export async function updateChatTitle(chatId: string, title: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, { const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH", method: "PATCH",
@@ -450,6 +462,7 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
let buffer = ""; let buffer = "";
let eventName = "message"; let eventName = "message";
let dataLines: string[] = []; let dataLines: string[] = [];
let sawTerminalEvent = false;
const flushEvent = () => { const flushEvent = () => {
if (!dataLines.length) { if (!dataLines.length) {
@@ -466,6 +479,9 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
} }
dispatch(eventName, payload); dispatch(eventName, payload);
if (eventName === "done" || eventName === "error") {
sawTerminalEvent = true;
}
dataLines = []; dataLines = [];
eventName = "message"; eventName = "message";
@@ -505,6 +521,10 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
} }
} }
flushEvent(); flushEvent();
if (!sawTerminalEvent) {
throw new Error("Stream disconnected before completion");
}
} }
export async function runSearchStream( export async function runSearchStream(
@@ -528,87 +548,14 @@ export async function runSearchStream(
signal: options?.signal, signal: options?.signal,
}); });
if (!response.ok) { await readSseStream(response, (eventName, payload) => {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
if (eventName === "search_results") handlers.onSearchResults?.(payload); if (eventName === "search_results") handlers.onSearchResults?.(payload);
else if (eventName === "search_error") handlers.onSearchError?.(payload); else if (eventName === "search_error") handlers.onSearchError?.(payload);
else if (eventName === "answer") handlers.onAnswer?.(payload); else if (eventName === "answer") handlers.onAnswer?.(payload);
else if (eventName === "answer_error") handlers.onAnswerError?.(payload); else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
else if (eventName === "done") handlers.onDone?.(payload); else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload); else if (eventName === "error") handlers.onError?.(payload);
});
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
} }
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) { export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
@@ -654,6 +601,7 @@ export async function runCompletionStream(
body: { body: {
chatId?: string | null; chatId?: string | null;
persist?: boolean; persist?: boolean;
clientRequestId?: string;
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
@@ -679,86 +627,13 @@ export async function runCompletionStream(
signal: options?.signal, signal: options?.signal,
}); });
if (!response.ok) { await readSseStream(response, (eventName, payload) => {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
if (eventName === "meta") handlers.onMeta?.(payload); if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload); else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload); else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload); else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload); else if (eventName === "error") handlers.onError?.(payload);
});
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
} }
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) { export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
+82
View File
@@ -0,0 +1,82 @@
export type ForkGroupingItem = {
kind: "chat" | "search";
id: string;
parentChatId: string | null;
};
type ChatTitleState = {
title: string | null;
titleGenerationPending: boolean;
};
type ForkPresentationItem = ForkGroupingItem & {
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export function groupForkedChatItems<T extends ForkGroupingItem>(items: T[]): T[][] {
const groups = new Map<string, { firstIndex: number; items: Array<{ item: T; index: number }> }>();
items.forEach((item, index) => {
const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`;
const group = groups.get(groupKey);
if (group) {
group.items.push({ item, index });
return;
}
groups.set(groupKey, { firstIndex: index, items: [{ item, index }] });
});
return [...groups.values()]
.sort((a, b) => a.firstIndex - b.firstIndex)
.map((group) =>
group.items
.sort((a, b) => {
const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null;
const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null;
if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1;
return a.index - b.index;
})
.map(({ item }) => item)
);
}
export function filterSidebarItemsWithForkGroups<T extends ForkGroupingItem>(items: T[], matches: (item: T) => boolean): T[] {
return groupForkedChatItems(items)
.flatMap((group) => {
const matchingItems = group.filter(matches);
if (!matchingItems.length) return [];
const root = group.find((item) => item.kind === "chat" && item.parentChatId === null);
if (!root || matchingItems.includes(root)) return group;
return group.filter((item) => item === root || matchingItems.includes(item));
});
}
export function getForkGroupPresentation<T extends ForkPresentationItem>(groupItems: T[], allItems: T[]) {
const firstItem = groupItems[0];
if (!firstItem) return null;
const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null;
const familyItems = rootId
? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId))
: groupItems;
const updatedAt = familyItems.reduce(
(newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest),
firstItem.updatedAt
);
const starOwner = rootId
? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null)
: firstItem;
return {
updatedAt,
starred: starOwner?.starred ?? false,
starredAt: starOwner?.starredAt ?? null,
};
}
export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) {
if (!chat || isRequestInFlight) return false;
return chat.titleGenerationPending || !chat.title?.trim();
}
+24
View File
@@ -0,0 +1,24 @@
import type { Provider } from "./api";
type PersistedChatModel = {
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type ChatModelSelection = {
provider: Provider;
model: string;
};
export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return {
provider: chat.lastUsedProvider,
model: chat.lastUsedModel.trim(),
};
}
export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) {
if (!chatId || !selection) return null;
return JSON.stringify([chatId, selection.provider, selection.model]);
}
+31
View File
@@ -0,0 +1,31 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection;
selectFallback?: boolean;
};
export function resolveSidebarSelectionAfterRefresh(
current: SidebarSelection | null,
workspaceItems: WorkspaceSelectionItem[],
{ initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {}
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};
if (hasItem(current)) {
return current;
}
if (hasItem(initialSelection)) {
return initialSelection ?? null;
}
if (!selectFallback) {
return null;
}
const first = workspaceItems[0];
return first ? { kind: first.type, id: first.id } : null;
}
+4 -1
View File
@@ -2,7 +2,10 @@ export function registerServiceWorker() {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
window.addEventListener("load", () => { window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => { void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.then((registration) => registration.update())
.catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error); console.warn("Sybil service worker registration failed", error);
}); });
}); });
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
filterSidebarItemsWithForkGroups,
getForkGroupPresentation,
groupForkedChatItems,
shouldRequestChatTitle,
} from "../src/lib/chat-forking.ts";
const root = { kind: "chat", id: "root", parentChatId: null };
const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" };
const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" };
const search = { kind: "search", id: "search", parentChatId: null };
test("fork groups are positioned by their newest member and always render root first", () => {
assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [
[root, nestedFork, firstFork],
[search],
]);
});
test("sidebar filtering keeps a matching fork with its root", () => {
assert.deepEqual(
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"),
[root, nestedFork]
);
});
test("sidebar filtering keeps all children when their root matches", () => {
assert.deepEqual(
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"),
[root, firstFork, nestedFork]
);
});
test("a filtered family keeps its full-family date and root-owned star state", () => {
const fullFamily = [
{ ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" },
{ ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null },
{ ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null },
];
const filteredFamily = [fullFamily[0], fullFamily[1]];
assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), {
updatedAt: "2026-08-16T00:00:00.000Z",
starred: true,
starredAt: "2026-08-15T00:00:00.000Z",
});
});
test("a fork placeholder title is replaced on its first submitted prompt", () => {
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true);
assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false);
});
test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => {
assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true);
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false);
});
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getChatModelSelection,
getChatModelSelectionSyncKey,
} from "../src/lib/chat-model-selection.ts";
test("chat model selections are normalized from persisted metadata", () => {
assert.deepEqual(
getChatModelSelection({
lastUsedProvider: "anthropic",
lastUsedModel: " claude-sonnet-4-5 ",
}),
{
provider: "anthropic",
model: "claude-sonnet-4-5",
}
);
});
test("unrelated chat updates do not change the model synchronization key", () => {
const beforeSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
const afterSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
assert.equal(
getChatModelSelectionSyncKey("chat-1", beforeSettingsSave),
getChatModelSelectionSyncKey("chat-1", afterSettingsSave)
);
});
test("switching chats or persisted models changes the synchronization key", () => {
const original = { provider: "openai", model: "gpt-4.1-mini" };
const updated = { provider: "gemini", model: "gemini-3.5-flash" };
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-2", original)
);
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-1", updated)
);
});
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const workspaceItems = [
{ type: "chat", id: "completed-chat" },
{ type: "chat", id: "selected-chat" },
{ type: "search", id: "selected-search" },
];
test("a collection refresh preserves the current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
{ kind: "chat", id: "selected-chat" }
);
});
test("an initial route selection cannot override a current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(
{ kind: "search", id: "selected-search" },
workspaceItems,
{ initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true }
),
{ kind: "search", id: "selected-search" }
);
});
test("a collection refresh preserves an intentionally empty selection", () => {
assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null);
});
test("initial load can select the URL thread or fall back to the first item", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(null, workspaceItems, {
initialSelection: { kind: "search", id: "selected-search" },
selectFallback: true,
}),
{ kind: "search", id: "selected-search" }
);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), {
kind: "chat",
id: "completed-chat",
});
});
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}