Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4389f805f4 | ||
|
|
e83281c400 | ||
|
|
ef3d79c428 | ||
|
|
dd6cd1ea7b | ||
|
|
3f9c1409fe | ||
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f | ||
|
|
26d917e96a | ||
|
|
922172fc60 | ||
|
|
5f9fc82b86 | ||
|
|
eb2b0d3ca0 | ||
|
|
42022bf055 | ||
|
|
1ef491f39a | ||
|
|
b540f01759 | ||
|
|
e719cbb92d | ||
|
|
cb5a973ac7 | ||
|
|
e2443162b0 | ||
|
|
048456b8a5 | ||
|
|
211fad972a | ||
|
|
a69c641481 | ||
|
|
9229896ad7 | ||
|
|
c5217b2710 | ||
|
|
abc1124d27 | ||
|
|
4721717022 | ||
|
|
87b7d9502f | ||
|
|
1952f4f358 | ||
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 | ||
|
|
a0e410155d | ||
|
|
b5a5d21767 | ||
|
|
0ad96f4f99 | ||
|
|
b0c2b74c16 | ||
|
|
14d2d8cb28 | ||
|
|
03836bbc4c | ||
|
|
76ce62025a | ||
|
|
b15473d24e | ||
|
|
a512a65844 | ||
|
|
207b44f67f | ||
|
|
c5ccd212c9 | ||
|
|
ee990dde5d | ||
|
|
036743fe41 | ||
|
|
4e7ea7ab68 | ||
|
|
f232013e5a | ||
|
|
27c425f664 | ||
|
|
297b053a91 | ||
|
|
7436544a69 | ||
|
|
95796646b1 | ||
|
|
d7214c88ad | ||
|
|
22aa652257 | ||
|
|
8f6e8c17a5 | ||
|
|
fccc8110f4 | ||
|
|
f71b69ca8b | ||
|
|
dda20955bb | ||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
|
|
600bc3befc | ||
|
|
5b7ed25522 | ||
|
|
39014eee18 | ||
|
|
a6c2ec664b | ||
|
|
cb8ea935fa | ||
|
|
f79e5e02c5 | ||
|
|
411790ee04 | ||
|
|
a8e765e026 | ||
|
|
29c6dce0e5 | ||
|
|
5855b7edb8 | ||
|
|
ac6d55f617 | ||
|
|
1e045db7f4 |
@@ -0,0 +1,39 @@
|
||||
name: TestFlight
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/ios/v*.*.*"
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
name: Build and upload
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Check out the release tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3.11"
|
||||
bundler-cache: true
|
||||
working-directory: ios
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY: ${{ secrets.ASC_KEY }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
CI: "true"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
run: bundle exec fastlane ios beta
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
|
||||
ios/fastlane/README.md
|
||||
ios/fastlane/report.xml
|
||||
|
||||
Vendored
+37
@@ -1,3 +1,8 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -12,12 +17,44 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://server:8787/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
|
||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||
|
||||
+256
-19
@@ -31,15 +31,35 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
- 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, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
|
||||
## Chat Tools
|
||||
|
||||
### `GET /v1/chat-tools`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{ "name": "web_search", "description": "..." },
|
||||
{ "name": "fetch_url", "description": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
|
||||
@@ -57,6 +77,63 @@ Behavior notes:
|
||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||
- The lists are not durable across server restarts.
|
||||
|
||||
## Workspace Items
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
|
||||
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "chat",
|
||||
"id": "chat-id",
|
||||
"title": "optional title",
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": true,
|
||||
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
"lastUsedModel": "gpt-4.1-mini",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
},
|
||||
{
|
||||
"type": "search",
|
||||
"id": "search-id",
|
||||
"title": "optional title",
|
||||
"query": "search query",
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": false,
|
||||
"starredAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
History search (`q`) notes:
|
||||
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
|
||||
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
|
||||
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
|
||||
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
|
||||
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
|
||||
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
|
||||
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -67,8 +144,10 @@ Behavior notes:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system|user|assistant|tool",
|
||||
@@ -83,14 +162,47 @@ Behavior notes:
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||
|
||||
### `POST /v1/chats/:chatId/fork`
|
||||
- Body: `{ "messageId"?: string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Source chat not found: `404 { "message": "chat not found" }`
|
||||
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
|
||||
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
|
||||
|
||||
Behavior notes:
|
||||
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
||||
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
||||
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
||||
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
||||
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
||||
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: `{ "title": string }`
|
||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
|
||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
### `PATCH /v1/chats/:chatId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the chat transcript or chat `updatedAt`.
|
||||
|
||||
### `POST /v1/chats/title/suggest`
|
||||
- Body:
|
||||
```json
|
||||
@@ -102,12 +214,19 @@ Behavior notes:
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
Behavior notes:
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
||||
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
|
||||
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
|
||||
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
|
||||
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
|
||||
|
||||
### `GET /v1/chats/:chatId`
|
||||
- Response: `{ "chat": ChatDetail }`
|
||||
@@ -154,7 +273,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -182,6 +301,8 @@ Notes:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -201,21 +322,28 @@ Notes:
|
||||
Behavior notes:
|
||||
- If `chatId` is present, server validates chat existence.
|
||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
|
||||
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
|
||||
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
|
||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||
- Devbox tool configuration:
|
||||
@@ -230,8 +358,17 @@ Behavior notes:
|
||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- `anthropic` currently runs without server-managed tool calls.
|
||||
- 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
|
||||
|
||||
@@ -239,8 +376,24 @@ Behavior notes:
|
||||
- Response: `{ "searches": SearchSummary[] }`
|
||||
|
||||
### `POST /v1/searches`
|
||||
- Body: `{ "title"?: string, "query"?: string }`
|
||||
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
|
||||
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
|
||||
|
||||
Behavior notes:
|
||||
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
|
||||
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
|
||||
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
|
||||
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
|
||||
|
||||
### `PATCH /v1/searches/:searchId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Response: `{ "search": SearchSummary }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the search results or search `updatedAt`.
|
||||
|
||||
### `DELETE /v1/searches/:searchId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -312,12 +465,18 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null"
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -360,19 +519,25 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [Message]
|
||||
}
|
||||
```
|
||||
|
||||
`SearchSummary`
|
||||
```json
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||
```
|
||||
|
||||
`SearchDetail`
|
||||
@@ -383,6 +548,8 @@ Behavior notes:
|
||||
"query": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"requestId": "...",
|
||||
"latencyMs": 123,
|
||||
"error": null,
|
||||
@@ -395,3 +562,73 @@ Behavior notes:
|
||||
```
|
||||
|
||||
For streaming contracts, see `docs/api/streaming-chat.md`.
|
||||
|
||||
## Sybil Agent Room
|
||||
|
||||
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
|
||||
background work, and history; these endpoints do not create ordinary Sybil chats or
|
||||
copy the transcript into the Sybil message database. All endpoints use normal Sybil
|
||||
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
|
||||
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
|
||||
|
||||
### `GET /v1/agent`
|
||||
|
||||
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
|
||||
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
|
||||
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
|
||||
configuration returns 503; an unreachable agent server or malformed upstream JSON
|
||||
returns 502.
|
||||
|
||||
### `GET /v1/agent/messages`
|
||||
|
||||
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
|
||||
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
|
||||
history. `after` catches up forward; `before` loads earlier history.
|
||||
|
||||
```json
|
||||
{
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
|
||||
"cursor": "43",
|
||||
"beforeCursor": "43",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Messages use `user`, `assistant`, and `tool` roles. Tools use
|
||||
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
|
||||
pages can update initiated tools to completed or failed. Internal worker reports,
|
||||
event prompts, and model reasoning are excluded. Cursors refer to underlying
|
||||
durable entries and can advance without visible messages. Use returned cursors;
|
||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||
messages.
|
||||
|
||||
### `POST /v1/agent/messages`
|
||||
|
||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||
Content must contain non-whitespace text and is limited to 40,000 characters;
|
||||
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
|
||||
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
|
||||
`wait: false`.
|
||||
|
||||
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
|
||||
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
|
||||
completion: watch the room for answers, including unsolicited later replies.
|
||||
Multiple requests can be accepted while the agent works. Attachments are not yet supported.
|
||||
The proxy verifies successful receipts contain a positive submission ID, a known
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
|
||||
Invalid receipts return 502; delivery remains uncertain, so retry using the original
|
||||
`requestId` to avoid creating a duplicate message.
|
||||
|
||||
### `GET /v1/agent/read-state`
|
||||
### `PUT /v1/agent/read-state`
|
||||
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
|
||||
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
|
||||
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
|
||||
a stale browser from lowering a cursor saved by another browser. Advance it only
|
||||
after showing the corresponding history; poll it to reconcile unread indicators
|
||||
across clients. Agent REST responses use `Cache-Control: no-store`.
|
||||
|
||||
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
|
||||
|
||||
+130
-10
@@ -1,27 +1,29 @@
|
||||
# Streaming Chat API Contract
|
||||
|
||||
This document defines the server-sent events (SSE) contract for chat completions.
|
||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/chat-completions/stream`
|
||||
- `POST /v1/chats/:chatId/stream/attach`
|
||||
- `POST /v1/quick-questions/stream`
|
||||
|
||||
Transport:
|
||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||
- Request body is JSON
|
||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
||||
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||
|
||||
Authentication:
|
||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||
|
||||
## Request Body
|
||||
## Chat Completion Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"clientRequestId": "optional-client-generated-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -49,6 +51,8 @@ Authentication:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -59,13 +63,21 @@ Notes:
|
||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||
- If `chatId` is provided, backend validates it exists.
|
||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
|
||||
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
|
||||
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
|
||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||
|
||||
## Attach Endpoint
|
||||
@@ -79,6 +91,32 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
|
||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||
|
||||
## Quick Question Endpoint
|
||||
|
||||
`POST /v1/quick-questions/stream`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"question": "What is the capital of France?",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"userLocation": "optional city, region, country",
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
|
||||
- `question` is required, trimmed by the server, and must not be empty.
|
||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
||||
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
||||
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
||||
|
||||
## Event Stream Contract
|
||||
|
||||
Event order:
|
||||
@@ -87,6 +125,8 @@ Event order:
|
||||
3. Zero or more `delta`
|
||||
4. Exactly one terminal event: `done` or `error`
|
||||
|
||||
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
|
||||
|
||||
### `meta`
|
||||
|
||||
```json
|
||||
@@ -111,6 +151,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
|
||||
### `tool_call`
|
||||
|
||||
```json
|
||||
{
|
||||
"toolCallId": "call_123",
|
||||
"name": "web_search",
|
||||
"status": "initiated",
|
||||
"summary": "Searching web for 'latest CPI release'.",
|
||||
"args": { "query": "latest CPI release" },
|
||||
"startedAt": "2026-03-02T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Terminal tool-call event:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolCallId": "call_123",
|
||||
@@ -121,11 +174,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
"startedAt": "2026-03-02T10:00:00.000Z",
|
||||
"completedAt": "2026-03-02T10:00:00.820Z",
|
||||
"durationMs": 820,
|
||||
"error": null,
|
||||
"resultPreview": "{\"ok\":true,...}"
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
|
||||
|
||||
### `done`
|
||||
|
||||
```json
|
||||
@@ -151,18 +205,23 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
## Provider Streaming Behavior
|
||||
|
||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
|
||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. 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.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||
|
||||
Tool-enabled streaming notes (`openai`/`xai`):
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||
- Stream may emit `tool_call` events while tool calls are executed.
|
||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
@@ -174,7 +233,8 @@ Backend database remains source of truth.
|
||||
|
||||
For persisted streams:
|
||||
- Client may optimistically render accumulated `delta` text.
|
||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
- Backend emits initiated tool-call events without persisting them.
|
||||
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
|
||||
On successful persisted completion:
|
||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||
@@ -215,3 +275,63 @@ event: done
|
||||
data: {"type":"done","text":"Hello world"}
|
||||
|
||||
```
|
||||
|
||||
## Sybil Agent WebSocket
|
||||
|
||||
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
|
||||
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
|
||||
connected after each assistant turn and receives later background results. Sending
|
||||
messages uses `POST /v1/agent/messages`.
|
||||
|
||||
The browser must send this first frame within five seconds:
|
||||
|
||||
```json
|
||||
{ "type": "auth", "token": "Sybil token", "after": "43" }
|
||||
```
|
||||
|
||||
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
|
||||
the last successfully merged cursor. Invalid credentials close with 4401, malformed
|
||||
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
|
||||
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
|
||||
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
|
||||
browser credentials are not forwarded to the agent.
|
||||
|
||||
Native `pi-room.v1` frames are forwarded after authentication. This protocol
|
||||
identifier is retained for compatibility with existing clients:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "messages",
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
|
||||
"cursor": "44",
|
||||
"beforeCursor": "44",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
|
||||
```
|
||||
|
||||
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
|
||||
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
|
||||
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
|
||||
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
|
||||
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
||||
applying its entire frame, even when it contains no visible messages.
|
||||
|
||||
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
||||
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
||||
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
||||
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
|
||||
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
|
||||
cursor. Missing agent configuration closes with 4503.
|
||||
|
||||
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
||||
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ASC_KEY_ID=
|
||||
ASC_ISSUER_ID=
|
||||
ASC_KEY=
|
||||
MATCH_PASSWORD=
|
||||
MATCH_GIT_BASIC_AUTHORIZATION=
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
*.xcodeproj
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/
|
||||
fastlane/test_output/
|
||||
|
||||
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||
|
||||
## Release Workflow
|
||||
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
|
||||
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
|
||||
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
|
||||
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
|
||||
|
||||
## App Structure
|
||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||
- Shared iOS app code lives in Swift package:
|
||||
@@ -51,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- OpenAI: `gpt-4.1-mini`
|
||||
- Anthropic: `claude-3-5-sonnet-latest`
|
||||
- xAI: `grok-3-mini`
|
||||
- Gemini: `gemini-3.5-flash`
|
||||
- Hermes Agent: `hermes-agent`
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -8,7 +8,7 @@ struct SybilApp: App
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if scene is UIWindowScene, session.role == .windowApplication {
|
||||
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
|
||||
@@ -11,6 +11,13 @@ targets:
|
||||
dependencies:
|
||||
- package: Sybil
|
||||
product: Sybil
|
||||
- target: SybilMacQuickQuestion
|
||||
destinationFilters: [macCatalyst]
|
||||
embed: true
|
||||
link: false
|
||||
codeSign: true
|
||||
copy:
|
||||
destination: plugins
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
@@ -24,14 +31,21 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.7
|
||||
CURRENT_PROJECT_VERSION: 8
|
||||
MARKETING_VERSION: "1.13.2"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
|
||||
|
||||
schemes:
|
||||
Sybil:
|
||||
|
||||
@@ -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 Sybil’s 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
|
||||
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "2.237.0"
|
||||
@@ -0,0 +1,244 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1274.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.228.1)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (1.6.0)
|
||||
logger
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.65.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.237.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
@@ -24,7 +24,8 @@ let package = Package(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
]),
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
.testTarget(
|
||||
name: "SybilTests",
|
||||
dependencies: ["Sybil"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "character-still.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
@@ -101,12 +106,18 @@ public struct SplitView: View {
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: isQuickQuestionPresented) { _, presented in
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
@@ -151,8 +162,13 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
SybilMacQuickQuestionController.shared.show()
|
||||
#else
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
@@ -161,23 +177,57 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
public static let workspaceWindowID = "sybil-workspace"
|
||||
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
windowCommands.newChatOrWindow(
|
||||
newChat: keyboardActions?.newChat,
|
||||
openWindow: openNewWindow
|
||||
)
|
||||
#else
|
||||
keyboardActions?.newChat()
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||
#else
|
||||
.disabled(keyboardActions == nil)
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button("New Window", action: openNewWindow)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||
#else
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Divider()
|
||||
Button("Quick Question") {
|
||||
SybilMacQuickQuestionController.shared.toggle()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: .option)
|
||||
#endif
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
@@ -194,6 +244,13 @@ public struct SybilCommands: Commands {
|
||||
.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 {
|
||||
|
||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -40,10 +40,66 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
|
||||
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
|
||||
var query = [URLQueryItem(name: "limit", value: "200")]
|
||||
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
|
||||
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
|
||||
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
|
||||
try page.validate()
|
||||
return page
|
||||
}
|
||||
|
||||
func agentReadState() async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: requestID)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
task.resume()
|
||||
let socket = AgentURLSocket(task: task)
|
||||
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
|
||||
catch { await socket.close(); throw error }
|
||||
return socket
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
|
||||
return response.items
|
||||
}
|
||||
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
|
||||
try await request(
|
||||
"/v1/workspace-items",
|
||||
method: "GET",
|
||||
queryItems: [URLQueryItem(name: "q", value: query)],
|
||||
responseType: WorkspaceSearchResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
@@ -69,6 +125,36 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/fork",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -113,6 +199,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
let response = try await request(
|
||||
"/v1/searches/\(searchID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: SearchCreateResponse.self
|
||||
)
|
||||
return response.search
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -148,6 +244,29 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||
}
|
||||
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
let request = try makeRequest(
|
||||
path: "/v1/quick-questions/stream",
|
||||
method: "POST",
|
||||
body: AnyEncodable(body),
|
||||
acceptsSSE: true
|
||||
)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.network,
|
||||
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
||||
}
|
||||
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
@@ -222,9 +341,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: AnyEncodable? = nil,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
|
||||
|
||||
SybilLog.debug(
|
||||
SybilLog.network,
|
||||
@@ -276,12 +396,14 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
path: String,
|
||||
method: String,
|
||||
body: AnyEncodable?,
|
||||
acceptsSSE: Bool
|
||||
acceptsSSE: Bool,
|
||||
queryItems: [URLQueryItem] = []
|
||||
) throws -> URLRequest {
|
||||
let url = try buildURL(path: path)
|
||||
let url = try buildURL(path: path, queryItems: queryItems)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = 120
|
||||
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
|
||||
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
|
||||
|
||||
if acceptsSSE {
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
@@ -301,7 +423,7 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return request
|
||||
}
|
||||
|
||||
private func buildURL(path: String) throws -> URL {
|
||||
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
|
||||
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidBaseURL
|
||||
}
|
||||
@@ -312,6 +434,9 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
basePath.removeLast()
|
||||
}
|
||||
components.path = "\(basePath)/\(trimmedPath)"
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = (components.queryItems ?? []) + queryItems
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidBaseURL
|
||||
@@ -626,6 +751,13 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var question: String
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
@@ -635,6 +767,18 @@ private struct ChatCreateBody: Encodable {
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct ChatForkBody: Encodable {
|
||||
var messageId: String?
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
private struct SearchCreateBody: Encodable {
|
||||
var title: String?
|
||||
var query: String?
|
||||
|
||||
@@ -2,6 +2,8 @@ import Foundation
|
||||
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(
|
||||
title: String?,
|
||||
@@ -10,12 +12,16 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||
func listSearches() async throws -> [SearchSummary]
|
||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||
func getSearch(searchID: String) async throws -> SearchDetail
|
||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
||||
func deleteSearch(searchID: String) async throws
|
||||
func listModels() async throws -> ModelCatalogResponse
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||
@@ -23,6 +29,10 @@ protocol SybilAPIClienting: Sendable {
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import Foundation
|
||||
|
||||
struct AgentFailure: LocalizedError, Sendable {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
// Compare decimal strings without losing precision through Double or overflowing Int.
|
||||
enum AgentCursor {
|
||||
static func normalize(_ value: String) throws -> String {
|
||||
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
|
||||
throw AgentFailure(message: "Invalid Agent cursor.")
|
||||
}
|
||||
let trimmed = value.drop(while: { $0 == "0" })
|
||||
return trimmed.isEmpty ? "0" : String(trimmed)
|
||||
}
|
||||
static func less(_ a: String, _ b: String) -> Bool {
|
||||
a.count == b.count ? a < b : a.count < b.count
|
||||
}
|
||||
static func advance(_ current: String, _ incoming: String) throws -> String {
|
||||
let value = try normalize(incoming)
|
||||
return less(current, value) ? value : current
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentActivity: Codable, Equatable, Sendable {
|
||||
struct Tool: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var name: String
|
||||
var status: String
|
||||
var output: String?
|
||||
}
|
||||
struct Wait: Codable, Equatable, Sendable {
|
||||
var due: Double
|
||||
var reason: String?
|
||||
}
|
||||
var state: String = "idle"
|
||||
var isThinking = false
|
||||
var queued = 0
|
||||
var backgroundWorkers = 0
|
||||
var tools: [Tool] = []
|
||||
var queuedMessages: [Message] = []
|
||||
var wait: Wait?
|
||||
var error: String?
|
||||
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
|
||||
}
|
||||
|
||||
struct AgentDescriptor: Decodable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
var type: String
|
||||
var model: String
|
||||
var `protocol`: String
|
||||
var activity: AgentActivity
|
||||
var websocket: String
|
||||
}
|
||||
struct AgentPage: Codable, Sendable {
|
||||
var conversationId: String
|
||||
var messages: [Message]
|
||||
var cursor: String?
|
||||
var beforeCursor: String?
|
||||
var hasMore: Bool
|
||||
|
||||
func validate() throws {
|
||||
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
|
||||
throw AgentFailure(message: "Invalid Agent history.")
|
||||
}
|
||||
if let cursor { _ = try AgentCursor.normalize(cursor) }
|
||||
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
|
||||
}
|
||||
}
|
||||
struct AgentReadState: Codable, Sendable {
|
||||
var cursor: String
|
||||
var updatedAt: String?
|
||||
}
|
||||
struct AgentReceipt: Codable, Sendable {
|
||||
var id: Int64
|
||||
var status: String
|
||||
var messageId: String
|
||||
func validate(requestID: String) throws {
|
||||
guard id > 0, id <= 9_007_199_254_740_991,
|
||||
["queued", "placed", "done", "unanswered"].contains(status),
|
||||
messageId == "inbound:sybil:\(requestID)" else {
|
||||
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
static func decode(_ data: Data) throws -> AgentEvent? {
|
||||
struct Envelope: Decodable {
|
||||
var type: String
|
||||
var conversationId: String?
|
||||
var activity: AgentActivity?
|
||||
var error: String?
|
||||
}
|
||||
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
|
||||
switch envelope.type {
|
||||
case "ready": return .ready(envelope.conversationId)
|
||||
case "messages":
|
||||
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
|
||||
try page.validate()
|
||||
return .messages(page)
|
||||
case "activity":
|
||||
guard let id = envelope.conversationId, let activity = envelope.activity else {
|
||||
throw AgentFailure(message: "Invalid Agent activity frame.")
|
||||
}
|
||||
return .activity(id, activity)
|
||||
case "pong", "heartbeat": return .heartbeat
|
||||
case "error": return .error(envelope.error ?? "Agent connection error.")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentCoding {
|
||||
static func decoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let text = try container.decode(String.self)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: text) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
guard let date = formatter.date(from: text) else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentEntryCursor: String? {
|
||||
let value = metadata?.objectValue?["entryId"]
|
||||
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
|
||||
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
|
||||
return String(Int64(number))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var isAgentPublicReply: Bool {
|
||||
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
|
||||
}
|
||||
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
|
||||
}
|
||||
|
||||
struct AgentRoomState: Equatable, Sendable {
|
||||
var conversationID: String?
|
||||
var messages: [Message] = []
|
||||
var cursor = "0"
|
||||
var readCursor = "0"
|
||||
var activity = AgentActivity()
|
||||
var unreadCount: Int {
|
||||
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
|
||||
}
|
||||
mutating func checkConversation(_ id: String) throws {
|
||||
guard conversationID == nil || conversationID == id else {
|
||||
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
|
||||
}
|
||||
conversationID = id
|
||||
}
|
||||
mutating func apply(_ page: AgentPage) throws {
|
||||
try page.validate()
|
||||
var next = self
|
||||
try next.checkConversation(page.conversationId)
|
||||
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
|
||||
next.messages = Self.merge(messages, page.messages)
|
||||
self = next
|
||||
}
|
||||
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
|
||||
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
for var message in incoming {
|
||||
if let old = byID[message.id] {
|
||||
let prior = old.metadata?.objectValue ?? [:]
|
||||
let next = message.metadata?.objectValue ?? [:]
|
||||
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
|
||||
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
|
||||
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
|
||||
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
|
||||
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
|
||||
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
|
||||
message.metadata = .object(merged)
|
||||
message.createdAt = min(old.createdAt, message.createdAt)
|
||||
if stale { message.content = old.content; message.name = old.name }
|
||||
}
|
||||
}
|
||||
byID[message.id] = message
|
||||
}
|
||||
return byID.values.sorted {
|
||||
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
|
||||
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The transcript latch is independent of the truthful sidebar/worker activity.
|
||||
struct AgentPresentation {
|
||||
private(set) var suppressed = false
|
||||
private(set) var busySince: Date?
|
||||
private var seenTools: Set<String> = []
|
||||
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
||||
|
||||
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
||||
switch event {
|
||||
case let .messages(page):
|
||||
let existing = Set(before.messages.map(\.id))
|
||||
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
|
||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||
if !replay {
|
||||
for message in fresh {
|
||||
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
||||
else if message.role == .user { suppressed = false }
|
||||
else if message.toolCallMetadata?.status == "initiated",
|
||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
||||
}
|
||||
}
|
||||
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
|
||||
case let .activity(_, activity):
|
||||
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
|
||||
seenTools.formUnion(activity.tools.map(\.id))
|
||||
default: break
|
||||
}
|
||||
reconcile(activity: after.activity, now: now)
|
||||
}
|
||||
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
|
||||
private mutating func reconcile(activity: AgentActivity, now: Date) {
|
||||
if suppressed || !activity.isBusy { busySince = nil }
|
||||
else if busySince == nil { busySince = now }
|
||||
}
|
||||
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct AgentOutboxRow: Codable, Equatable, Sendable {
|
||||
var requestID: String
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var status: String = "sending"
|
||||
var seenOnServer = false
|
||||
var confirmed = false
|
||||
var error: String?
|
||||
var id: String { "inbound:sybil:\(requestID)" }
|
||||
var message: Message {
|
||||
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
|
||||
if let error { info["error"] = .string(error) }
|
||||
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOutboxStorage: Sendable {
|
||||
var read: @Sendable () throws -> [AgentOutboxRow]
|
||||
var write: @Sendable ([AgentOutboxRow]) throws -> Void
|
||||
|
||||
static func file(baseURL: URL) -> Self {
|
||||
// Separate installations, but never persist tokens in a filename or payload.
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
|
||||
let url = directory.appendingPathComponent("\(key).json")
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
|
||||
}, write: { rows in
|
||||
let data = try JSONEncoder().encode(rows)
|
||||
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
|
||||
throw AgentFailure(message: "Retry pending messages before sending more.")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SybilAgentRoom {
|
||||
struct Snapshot: Equatable {
|
||||
var room = AgentRoomState()
|
||||
var isTyping = false
|
||||
var entranceIDs: Set<String> = []
|
||||
var revision = 0
|
||||
}
|
||||
private(set) var snapshot = Snapshot()
|
||||
private(set) var connection = "Offline"
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
var composer = ""
|
||||
var isObscured = false
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentOutboxStorage?
|
||||
@ObservationIgnored private var storageReadable = true
|
||||
@ObservationIgnored private var pending = AgentRoomState()
|
||||
@ObservationIgnored private var presenter = AgentPresentation()
|
||||
@ObservationIgnored private var pendingEntrances: Set<String> = []
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var connectionTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var batchTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var typingTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var readTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored private var socket: (any AgentSocket)?
|
||||
@ObservationIgnored private var monitor: NWPathMonitor?
|
||||
@ObservationIgnored private var lastPacket = Date()
|
||||
@ObservationIgnored private var socketStarted = Date()
|
||||
@ObservationIgnored private var desiredRead = "0"
|
||||
@ObservationIgnored private var acknowledgedRead = "0"
|
||||
@ObservationIgnored private var hasLoaded = false
|
||||
@ObservationIgnored private var syncing = false
|
||||
@ObservationIgnored private var wasNetworkOffline = false
|
||||
|
||||
var isBusy: Bool { snapshot.room.activity.isBusy }
|
||||
var unreadCount: Int { snapshot.room.unreadCount }
|
||||
var messages: [Message] {
|
||||
let committed = Set(snapshot.room.messages.map(\.id))
|
||||
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
||||
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
||||
for row in outbox {
|
||||
if let index = messages.firstIndex(where: { $0.id == row.id }) {
|
||||
messages[index].createdAt = row.createdAt
|
||||
if !row.confirmed {
|
||||
var info = messages[index].metadata?.objectValue ?? [:]
|
||||
info["status"] = .string(row.status)
|
||||
if let error = row.error { info["error"] = .string(error) }
|
||||
messages[index].metadata = .object(info)
|
||||
}
|
||||
} else { messages.append(row.message) }
|
||||
}
|
||||
return AgentRoomState.merge([], messages)
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
|
||||
stop()
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
var restored = row
|
||||
if !restored.seenOnServer {
|
||||
restored.status = "failed"
|
||||
restored.error = "Delivery was interrupted. Retry to confirm."
|
||||
}
|
||||
return restored
|
||||
}
|
||||
} catch {
|
||||
outbox = []; storageReadable = false
|
||||
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
stop()
|
||||
client = nil
|
||||
}
|
||||
|
||||
func stop() {
|
||||
generation = UUID()
|
||||
connectionTask?.cancel(); connectionTask = nil
|
||||
maintenanceTask?.cancel(); maintenanceTask = nil
|
||||
batchTask?.cancel(); batchTask = nil
|
||||
typingTask?.cancel(); typingTask = nil
|
||||
readTask?.cancel(); readTask = nil
|
||||
sends.values.forEach { $0.cancel() }; sends = [:]
|
||||
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
|
||||
outbox[index].status = "failed"
|
||||
outbox[index].error = "Delivery was interrupted. Retry to confirm."
|
||||
}
|
||||
if !outbox.isEmpty { try? persist() }
|
||||
monitor?.cancel(); monitor = nil
|
||||
let old = socket; socket = nil
|
||||
Task { await old?.close() }
|
||||
syncing = false
|
||||
pendingEntrances = []
|
||||
connection = "Offline"
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
guard isActive != active else { return }
|
||||
isActive = active
|
||||
if active { start() }
|
||||
else {
|
||||
// Publish successfully merged input before cancelling; replay resumes here.
|
||||
flush()
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
func retryConnection() { flush(); stop(); if isActive { start() } }
|
||||
|
||||
private func start() {
|
||||
guard isActive, let client, connectionTask == nil else { return }
|
||||
let run = generation
|
||||
isLoading = !hasLoaded
|
||||
connection = "Connecting"
|
||||
let monitor = NWPathMonitor()
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.generation == run else { return }
|
||||
let recovered = self.wasNetworkOffline && satisfied
|
||||
self.wasNetworkOffline = !satisfied
|
||||
if recovered { self.retryConnection() }
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
|
||||
self.monitor = monitor
|
||||
connectionTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var delay = 1
|
||||
while !Task.isCancelled, self.generation == run {
|
||||
do {
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
guard self.generation == run else { return }
|
||||
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.hasLoaded = true; self.isLoading = false; self.flush()
|
||||
let socket = try await client.agentSocket(after: self.pending.cursor)
|
||||
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
|
||||
self.socket = socket
|
||||
self.socketStarted = Date(); self.lastPacket = Date()
|
||||
while !Task.isCancelled, self.generation == run {
|
||||
let data = try await socket.receive()
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.lastPacket = Date()
|
||||
guard let event = try AgentEvent.decode(data) else { continue }
|
||||
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
|
||||
try self.receive(event)
|
||||
}
|
||||
} catch {
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
let failedSocket = self.socket
|
||||
let code = await failedSocket?.closeCode()
|
||||
await failedSocket?.close()
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.socket = nil
|
||||
self.isLoading = false
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
|
||||
self.connection = code == 4401 ? "Offline" : "Reconnecting"
|
||||
if code == 4401 { return }
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
delay = min(delay * 2, 30)
|
||||
}
|
||||
}
|
||||
maintenanceTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
guard let self, self.generation == run, !Task.isCancelled else { return }
|
||||
do {
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.flushRead()
|
||||
if self.connection != "Connected" {
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
}
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
if let socket = self.socket {
|
||||
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
|
||||
await socket.close()
|
||||
} else { try await socket.send("{\"type\":\"ping\"}") }
|
||||
}
|
||||
} catch {
|
||||
if self.generation == run { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
|
||||
let run = run ?? generation
|
||||
guard generation == run, !Task.isCancelled, !syncing else { return }
|
||||
syncing = true
|
||||
defer { if generation == run { syncing = false } }
|
||||
var cursor = pending.cursor
|
||||
while !Task.isCancelled, generation == run {
|
||||
let page = try await client.agentMessages(after: cursor, before: nil)
|
||||
guard generation == run, !Task.isCancelled else { return }
|
||||
try page.validate()
|
||||
let next = try AgentCursor.normalize(page.cursor ?? cursor)
|
||||
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
|
||||
throw AgentFailure(message: "Agent history did not advance its cursor.")
|
||||
}
|
||||
try receive(.messages(page), replay: replay)
|
||||
cursor = next
|
||||
if !page.hasMore { return }
|
||||
}
|
||||
}
|
||||
|
||||
func receive(_ event: AgentEvent, replay: Bool = false) throws {
|
||||
let before = pending
|
||||
var next = pending
|
||||
switch event {
|
||||
case let .messages(page): try next.apply(page)
|
||||
case let .activity(id, activity):
|
||||
try next.checkConversation(id)
|
||||
next.activity = activity
|
||||
case let .ready(id): if let id { try next.checkConversation(id) }
|
||||
case let .error(message): throw AgentFailure(message: message)
|
||||
case .heartbeat: return
|
||||
}
|
||||
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
|
||||
pending = next
|
||||
if !replay {
|
||||
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
|
||||
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
|
||||
}
|
||||
reconcileOutbox()
|
||||
schedulePublish()
|
||||
}
|
||||
|
||||
private func schedulePublish() {
|
||||
guard batchTask == nil else { return }
|
||||
batchTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func flush() {
|
||||
batchTask?.cancel(); batchTask = nil
|
||||
typingTask?.cancel(); typingTask = nil
|
||||
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
||||
pendingEntrances = []
|
||||
if let start = presenter.busySince, !snapshot.isTyping {
|
||||
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
|
||||
typingTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
|
||||
let read = try await client.agentReadState()
|
||||
guard generation == run else { return }
|
||||
try applyRead(read)
|
||||
}
|
||||
func applyRead(_ read: AgentReadState) throws {
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
|
||||
pending.readCursor = acknowledgedRead
|
||||
schedulePublish()
|
||||
}
|
||||
func markRead(renderedCursor: String) {
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return }
|
||||
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
private func flushRead() {
|
||||
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
let run = generation
|
||||
readTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.readTask = nil } }
|
||||
do {
|
||||
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
let captured = self.desiredRead
|
||||
let read = try await client.agentMarkRead(cursor: captured)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
|
||||
throw AgentFailure(message: "Agent read state was not acknowledged.")
|
||||
}
|
||||
try self.applyRead(read)
|
||||
}
|
||||
} catch { if self.generation == run { self.error = error.localizedDescription } }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String) -> Bool {
|
||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
|
||||
error = "Enter a message of at most 40,000 characters."; return false
|
||||
}
|
||||
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
|
||||
outbox.append(row)
|
||||
pendingEntrances.insert(row.id)
|
||||
presenter.sent(now: Date(), activity: pending.activity)
|
||||
flush()
|
||||
dispatch(row.id)
|
||||
return true
|
||||
}
|
||||
func retry(_ id: String) { dispatch(id) }
|
||||
private func persist() throws {
|
||||
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
|
||||
try storage.write(outbox.filter { !$0.confirmed })
|
||||
}
|
||||
private func dispatch(_ id: String) {
|
||||
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
||||
outbox[index].status = "sending"; outbox[index].error = nil
|
||||
do { try persist() }
|
||||
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
|
||||
let row = outbox[index], run = generation
|
||||
sends[id] = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.sends[id] = nil } }
|
||||
do {
|
||||
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
|
||||
try receipt.validate(requestID: row.requestID)
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
|
||||
self.outbox[index].seenOnServer = true
|
||||
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
|
||||
self.outbox[index].error = nil
|
||||
try? self.persist()
|
||||
} catch {
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
|
||||
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
|
||||
try? self.persist()
|
||||
}
|
||||
}
|
||||
}
|
||||
private func reconcileOutbox() {
|
||||
let committed = Set(pending.messages.map(\.id))
|
||||
let queued = Set(pending.activity.queuedMessages.map(\.id))
|
||||
var changed = false
|
||||
for index in outbox.indices {
|
||||
if committed.contains(outbox[index].id) {
|
||||
if !outbox[index].confirmed { changed = true }
|
||||
outbox[index].confirmed = true; outbox[index].seenOnServer = true
|
||||
outbox[index].status = "complete"; outbox[index].error = nil
|
||||
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
|
||||
changed = true
|
||||
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
||||
}
|
||||
}
|
||||
if changed { try? persist() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
protocol AgentSocket: Sendable {
|
||||
func receive() async throws -> Data
|
||||
func send(_ text: String) async throws
|
||||
func close() async
|
||||
func closeCode() async -> Int
|
||||
}
|
||||
|
||||
protocol AgentAPIClienting: Sendable {
|
||||
func agentDescriptor() async throws -> AgentDescriptor
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSocket(after: String) async throws -> any AgentSocket
|
||||
}
|
||||
|
||||
actor AgentURLSocket: AgentSocket {
|
||||
private let task: URLSessionWebSocketTask
|
||||
init(task: URLSessionWebSocketTask) { self.task = task }
|
||||
func receive() async throws -> Data {
|
||||
switch try await task.receive() {
|
||||
case let .string(text): return Data(text.utf8)
|
||||
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
|
||||
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
|
||||
}
|
||||
}
|
||||
func send(_ text: String) async throws { try await task.send(.string(text)) }
|
||||
func close() { task.cancel(with: .goingAway, reason: nil) }
|
||||
func closeCode() -> Int { task.closeCode.rawValue }
|
||||
}
|
||||
|
||||
extension APIConfiguration {
|
||||
func agentWebSocketURL() throws -> URL {
|
||||
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(parts.scheme), parts.host != nil,
|
||||
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
|
||||
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
|
||||
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
|
||||
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
|
||||
parts.query = nil
|
||||
parts.fragment = nil
|
||||
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
||||
return url
|
||||
}
|
||||
func agentAuthFrame(after: String) throws -> String {
|
||||
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value: JSONValue = .object([
|
||||
"type": .string("auth"),
|
||||
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
|
||||
"after": .string(try AgentCursor.normalize(after))
|
||||
])
|
||||
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SybilAgentView: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var topInset: CGFloat = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
@State private var scrollMetrics = AgentScrollMetrics()
|
||||
@State private var rowFrames: [String: CGRect] = [:]
|
||||
@State private var restoreAnchor: AgentReadingAnchor?
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
var body: some View {
|
||||
let rendered = room.snapshot
|
||||
let allMessages = room.messages
|
||||
let startIndex = historyStartID.flatMap { id in allMessages.firstIndex(where: { $0.id == id }) } ?? max(0, allMessages.count - historyLimit)
|
||||
let displayed = Array(allMessages.dropFirst(startIndex))
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
|
||||
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
VStack(spacing: 14) {
|
||||
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
Group {
|
||||
if message.isToolCallLog {
|
||||
AgentToolCard(message: message)
|
||||
} else {
|
||||
AgentMessageRow(message: message, retry: { room.retry(message.id) })
|
||||
}
|
||||
}
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||
})
|
||||
}
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.id(message.id)
|
||||
}
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
}
|
||||
Color.clear.frame(height: 1).id(bottomID)
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
isScrolling = phase.isScrolling
|
||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
||||
}
|
||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top)
|
||||
} action: { previous, next in
|
||||
scrollMetrics = next
|
||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||
if atLatest, !isScrolling, restoreAnchor == nil,
|
||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Restore the exact pixel offset after insertion, tool
|
||||
// expansion or text reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
|
||||
restoreAnchor = nil
|
||||
} else {
|
||||
rowFrames = frames
|
||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
} label: {
|
||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||
.overlay(Capsule().stroke(SybilTheme.border))
|
||||
}
|
||||
.padding(14)
|
||||
.accessibilityIdentifier("agent-jump-latest")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
appeared = true
|
||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: allMessages.count) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: composerFocused) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
statusBar
|
||||
composer
|
||||
}
|
||||
.padding(.top, topInset)
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
.task(id: readKey) {
|
||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||
// the task, and cannot be acknowledged while still entering the layout.
|
||||
let cursor = rendered.room.cursor
|
||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return }
|
||||
room.markRead(renderedCursor: cursor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var statusBar: some View {
|
||||
let activity = room.snapshot.room.activity
|
||||
if let error = room.error ?? activity.error {
|
||||
HStack(alignment: .top) {
|
||||
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
Spacer(minLength: 4)
|
||||
Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold))
|
||||
}.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
} else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil {
|
||||
HStack(spacing: 10) {
|
||||
if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") }
|
||||
if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") }
|
||||
if let wait = activity.wait {
|
||||
Text(wait.reason ?? "Waiting")
|
||||
Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.horizontal, 16).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
TextField("Message Sybil…", text: $room.composer, axis: .vertical)
|
||||
.lineLimit(1...6)
|
||||
.font(.sybil(.body))
|
||||
.focused($composerFocused)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
Button {
|
||||
let content = room.composer
|
||||
if room.send(content) { room.composer = ""; atLatest = true }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(SybilTheme.primaryGradient, in: Circle())
|
||||
}
|
||||
.disabled(room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room.composer.utf16.count > 40_000)
|
||||
.accessibilityLabel("Send message")
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 22))
|
||||
.overlay(RoundedRectangle(cornerRadius: 22).stroke(SybilTheme.border))
|
||||
.padding(.horizontal, 12).padding(.bottom, 10).padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAvatar: View {
|
||||
var body: some View {
|
||||
Image("character-still", bundle: .module).resizable().scaledToFill()
|
||||
.frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3)))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentMessageRow: View {
|
||||
var message: Message
|
||||
var retry: () -> Void
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if !own { AgentAvatar().padding(.bottom, 23) }
|
||||
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
Text(message.createdAt, style: .time)
|
||||
if own && status == "sending" { Text("Sending…") }
|
||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||
if own && status == "failed" {
|
||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
|
||||
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentToolCard: View {
|
||||
var message: Message
|
||||
@State private var expanded = false
|
||||
var body: some View {
|
||||
if let metadata = message.toolCallMetadata {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
expanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
ToolCallActivityChip(metadata: metadata, fallbackContent: message.content, createdAt: message.createdAt, compactLayout: true)
|
||||
.allowsHitTesting(false)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("agent-tool-\(metadata.toolName ?? "tool")")
|
||||
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
|
||||
if expanded {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let args = metadata.args { detail("Input", text: pretty(args)) }
|
||||
if let error = metadata.error { detail("Error", text: error) }
|
||||
if let result = metadata.result { detail("Result", text: pretty(result)) }
|
||||
else if let preview = metadata.resultPreview { detail("Result", text: preview) }
|
||||
if metadata.args == nil && metadata.result == nil && metadata.resultPreview == nil && metadata.error == nil {
|
||||
Text(message.content).font(.sybil(.caption))
|
||||
}
|
||||
}.padding(.top, 8).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(8)
|
||||
.background(SybilTheme.surface.opacity(0.8), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
private func detail(_ title: String, text: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-tool-\(title.lowercased())")
|
||||
Text(text).font(.system(size: 12, design: .monospaced)).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
private func pretty(_ value: JSONValue) -> String {
|
||||
if let text = value.stringValue { return text }
|
||||
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// Animate both occupied height and content, so incoming rows push older history up.
|
||||
private struct AgentGrowingLayout: Layout {
|
||||
var progress: CGFloat
|
||||
var animatableData: CGFloat { get { progress } set { progress = newValue } }
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
|
||||
}
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
private struct AgentEntrance: ViewModifier {
|
||||
var animate: Bool
|
||||
var reduceMotion: Bool
|
||||
@State private var entered = false
|
||||
func body(content: Content) -> some View {
|
||||
let preparing = animate && !entered && !reduceMotion
|
||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentScrollMetrics: Equatable {
|
||||
var offset: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
var offset: CGFloat
|
||||
var expectedCount: Int? = nil
|
||||
}
|
||||
private struct AgentRowFrames: PreferenceKey {
|
||||
static let defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,161 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var tailSpacerHeight: CGFloat = 0
|
||||
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
|
||||
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
|
||||
var bottomPinRequestID: Int = 0
|
||||
var isForking: Bool = false
|
||||
var onForkResponse: ((String) -> Void)? = nil
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
messages.contains { message in
|
||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
@State private var hasTrackedToolCallMessages = false
|
||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||
|
||||
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||
private var renderItems: [TranscriptRenderItem] {
|
||||
buildTranscriptRenderItems(from: messages)
|
||||
}
|
||||
private var toolCallMessageIDs: Set<String> {
|
||||
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
|
||||
}
|
||||
private var enteringToolCallMessageIDs: Set<String> {
|
||||
guard hasTrackedToolCallMessages else { return [] }
|
||||
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
|
||||
}
|
||||
private var toolCallMessageIDSignature: String {
|
||||
toolCallMessageIDs.sorted().joined(separator: "|")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
ForEach(messages.reversed()) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isStreamingPendingAssistant(message) {
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(
|
||||
key: SybilPendingAssistantHeightPreferenceKey.self,
|
||||
value: proxy.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.top, 24)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
groupID: id,
|
||||
messages: messages,
|
||||
entryAnimationIDs: enteringToolCallMessageIDs
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Color.clear
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||
.padding(.bottom, 18 + topContentInset)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
onViewportHeightChange?(proxy.size.height)
|
||||
syncKnownToolCallMessageIDs()
|
||||
}
|
||||
.onChange(of: proxy.size.height) { _, height in
|
||||
onViewportHeightChange?(height)
|
||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||
syncKnownToolCallMessageIDs()
|
||||
}
|
||||
.onChange(of: bottomPinRequestID) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
|
||||
onPendingAssistantHeightChange?(height)
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
|
||||
isSending && message.id.hasPrefix("temp-assistant-")
|
||||
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||
let action = {
|
||||
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.18), action)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncKnownToolCallMessageIDs() {
|
||||
guard !toolCallMessageIDs.isEmpty else { return }
|
||||
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
||||
hasTrackedToolCallMessages = true
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
enum TranscriptRenderItem: Identifiable {
|
||||
case message(Message)
|
||||
case toolGroup(id: String, messages: [Message])
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
return message.id
|
||||
case let .toolGroup(id, _):
|
||||
return "tool-group-\(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
|
||||
var items: [TranscriptRenderItem] = []
|
||||
var toolRun: [Message] = []
|
||||
|
||||
func flushToolRun() {
|
||||
guard !toolRun.isEmpty else { return }
|
||||
if toolRun.count == 1, let message = toolRun.first {
|
||||
items.append(.message(message))
|
||||
} else if let first = toolRun.first {
|
||||
items.append(.toolGroup(id: first.id, messages: toolRun))
|
||||
}
|
||||
toolRun.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.toolCallMetadata != nil {
|
||||
toolRun.append(message)
|
||||
} else {
|
||||
flushToolRun()
|
||||
items.append(.message(message))
|
||||
}
|
||||
}
|
||||
|
||||
flushToolRun()
|
||||
return items
|
||||
}
|
||||
|
||||
struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
var agentStyle = false
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -93,13 +165,46 @@ private struct MessageBubble: View {
|
||||
message.role == .user
|
||||
}
|
||||
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
private var isStreamingAssistant: Bool {
|
||||
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
private var canFork: Bool {
|
||||
message.role == .assistant &&
|
||||
!message.id.hasPrefix("temp-") &&
|
||||
onFork != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if canFork {
|
||||
bubbleContent
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = message.content
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
onFork?()
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(isForking)
|
||||
}
|
||||
} else {
|
||||
bubbleContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bubbleContent: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
@@ -118,25 +223,26 @@ private struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
if isStreamingAssistant {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||
.textSelection(.enabled)
|
||||
.background(
|
||||
Group {
|
||||
@@ -147,6 +253,10 @@ private struct MessageBubble: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
} else if agentStyle {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(Color.clear)
|
||||
@@ -176,10 +286,342 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantContentView: View {
|
||||
private enum Phase: Hashable {
|
||||
case pending
|
||||
case trace(Int)
|
||||
case answer
|
||||
}
|
||||
|
||||
var content: String
|
||||
var isSending: Bool
|
||||
var presentation: StreamingAssistantPresentation?
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var retainedTraceHeight: CGFloat = 0
|
||||
|
||||
private var traceText: String? {
|
||||
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trace.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
private var hasAnswer: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var phase: Phase {
|
||||
if hasAnswer {
|
||||
return .answer
|
||||
}
|
||||
if traceText != nil {
|
||||
return .trace(presentation?.traceRevision ?? 0)
|
||||
}
|
||||
return .pending
|
||||
}
|
||||
|
||||
private var traceTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .asymmetric(
|
||||
insertion: .opacity.combined(with: .offset(y: 5)),
|
||||
removal: .opacity.combined(with: .offset(y: -5))
|
||||
)
|
||||
}
|
||||
|
||||
private var answerTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .opacity.combined(with: .offset(y: 3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let traceText {
|
||||
Text(traceText)
|
||||
.font(.sybil(size: 15))
|
||||
.italic()
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.opacity(0.78)
|
||||
.textSelection(.disabled)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityLabel("Assistant is working")
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: StreamingTraceHeightPreferenceKey.self,
|
||||
value: geometry.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
|
||||
.transition(traceTransition)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.transition(answerTransition)
|
||||
.zIndex(2)
|
||||
}
|
||||
|
||||
if isSending && !hasAnswer && traceText == nil {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.transition(reduceMotion ? .identity : .opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
|
||||
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
|
||||
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
|
||||
retainedTraceHeight = measuredHeight
|
||||
}
|
||||
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
var y: CGFloat
|
||||
var scale: CGFloat
|
||||
var opacity: Double
|
||||
var zIndex: Double
|
||||
}
|
||||
|
||||
var groupID: String
|
||||
var messages: [Message]
|
||||
var entryAnimationIDs: Set<String>
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isExpanded = false
|
||||
|
||||
private let visibleCollapsedLimit = 4
|
||||
private let cardHeight: CGFloat = 62
|
||||
private let expandedGap: CGFloat = 10
|
||||
private let collapsedStepX: CGFloat = 11
|
||||
private let collapsedStepY: CGFloat = 10
|
||||
private let toggleSize: CGFloat = 32
|
||||
private let toggleGap: CGFloat = 12
|
||||
|
||||
private var animation: Animation? {
|
||||
reduceMotion ? nil : .easeInOut(duration: 0.34)
|
||||
}
|
||||
|
||||
private var visibleCollapsedCount: Int {
|
||||
min(messages.count, visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var hiddenCount: Int {
|
||||
max(0, messages.count - visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var containerHeight: CGFloat {
|
||||
if isExpanded {
|
||||
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
|
||||
}
|
||||
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
GeometryReader { geometry in
|
||||
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
|
||||
let toggleX = cardWidth + toggleGap
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
||||
let layout = layout(for: index)
|
||||
let depth = messages.count - index - 1
|
||||
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
||||
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
||||
|
||||
ToolCallStackCard(
|
||||
message: message,
|
||||
cardHeight: cardHeight,
|
||||
compactLayout: true,
|
||||
animateEntry: shouldAnimateEntry
|
||||
)
|
||||
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
||||
.scaleEffect(layout.scale, anchor: .topLeading)
|
||||
.opacity(layout.opacity)
|
||||
.offset(x: layout.x, y: layout.y)
|
||||
.zIndex(layout.zIndex)
|
||||
.allowsHitTesting(!isHidden)
|
||||
.accessibilityHidden(isHidden)
|
||||
}
|
||||
|
||||
if !isExpanded && hiddenCount > 0 {
|
||||
Text("+\(hiddenCount)")
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.58))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(animation) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.frame(width: toggleSize, height: toggleSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
|
||||
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
|
||||
.offset(x: toggleX, y: 8)
|
||||
.zIndex(Double(messages.count + 2))
|
||||
}
|
||||
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
|
||||
.animation(animation, value: isExpanded)
|
||||
}
|
||||
.frame(height: containerHeight)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func layout(for index: Int) -> CardLayout {
|
||||
if isExpanded {
|
||||
return CardLayout(
|
||||
x: 0,
|
||||
y: CGFloat(index) * (cardHeight + expandedGap),
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
zIndex: Double(messages.count - index)
|
||||
)
|
||||
}
|
||||
|
||||
let depth = messages.count - index - 1
|
||||
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
|
||||
let isHidden = depth >= visibleCollapsedLimit
|
||||
return CardLayout(
|
||||
x: CGFloat(visibleDepth) * collapsedStepX,
|
||||
y: CGFloat(visibleDepth) * collapsedStepY,
|
||||
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
|
||||
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
|
||||
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackCard: View {
|
||||
var message: Message
|
||||
var cardHeight: CGFloat
|
||||
var compactLayout: Bool
|
||||
var animateEntry: Bool
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var entryAnimationArmed = false
|
||||
@State private var didEnter = false
|
||||
|
||||
private var isPreparingEntry: Bool {
|
||||
(animateEntry || entryAnimationArmed) && !didEnter
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let metadata = message.toolCallMetadata {
|
||||
ToolCallActivityChip(
|
||||
metadata: metadata,
|
||||
fallbackContent: message.content,
|
||||
createdAt: message.createdAt,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: cardHeight, alignment: .top)
|
||||
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
|
||||
.offset(y: isPreparingEntry ? -8 : 0)
|
||||
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||
.opacity(isPreparingEntry ? 0.72 : 1)
|
||||
.onAppear {
|
||||
guard !didEnter, !entryAnimationArmed else { return }
|
||||
guard animateEntry else {
|
||||
didEnter = true
|
||||
return
|
||||
}
|
||||
entryAnimationArmed = true
|
||||
if reduceMotion {
|
||||
didEnter = true
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
||||
didEnter = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
var compactLayout: Bool = false
|
||||
|
||||
private var summary: String {
|
||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
@@ -223,11 +665,22 @@ private struct ToolCallActivityChip: View {
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
(metadata.status ?? "").lowercased() == "failed"
|
||||
visualState == .failed
|
||||
}
|
||||
|
||||
private var visualState: VisualState {
|
||||
switch (metadata.status ?? "").lowercased() {
|
||||
case "failed":
|
||||
return .failed
|
||||
case "initiated":
|
||||
return .initiated
|
||||
default:
|
||||
return .completed
|
||||
}
|
||||
}
|
||||
|
||||
private var detailLabel: String {
|
||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||
var pieces: [String] = [stateLabel]
|
||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||
pieces.append("\(durationMs) ms")
|
||||
}
|
||||
@@ -239,14 +692,14 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||
.fill(iconColor.opacity(0.13))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
@@ -255,12 +708,14 @@ private struct ToolCallActivityChip: View {
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(compactLayout ? 1 : nil)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: !compactLayout)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(toolLabel)
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||
.foregroundStyle(iconColor.opacity(0.90))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detailLabel)
|
||||
@@ -275,12 +730,45 @@ private struct ToolCallActivityChip: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||
.fill(backgroundGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
}
|
||||
|
||||
private var stateLabel: String {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return "Failed"
|
||||
case .initiated:
|
||||
return "Running"
|
||||
case .completed:
|
||||
return "Completed"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.danger
|
||||
case .initiated:
|
||||
return SybilTheme.warning
|
||||
case .completed:
|
||||
return SybilTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundGradient: LinearGradient {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.failedToolCallGradient
|
||||
case .initiated:
|
||||
return SybilTheme.runningToolCallGradient
|
||||
case .completed:
|
||||
return SybilTheme.toolCallGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 anthropic
|
||||
case xai
|
||||
case gemini
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case .openai: return "OpenAI"
|
||||
case .anthropic: return "Anthropic"
|
||||
case .xai: return "xAI"
|
||||
case .gemini: return "Gemini"
|
||||
case .hermesAgent: return "Hermes Agent"
|
||||
}
|
||||
}
|
||||
@@ -152,20 +154,186 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
public var lastUsedModel: String?
|
||||
}
|
||||
|
||||
extension ChatSummary {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
}
|
||||
|
||||
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
||||
case chat
|
||||
case search
|
||||
}
|
||||
|
||||
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var type: WorkspaceItemType
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var searchSnippet: String? = nil
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
public var lastUsedModel: String?
|
||||
|
||||
public init(chat: ChatSummary) {
|
||||
self.type = .chat
|
||||
self.id = chat.id
|
||||
self.title = chat.title
|
||||
self.query = nil
|
||||
self.parentChatId = chat.parentChatId
|
||||
self.titleGenerationPending = chat.titleGenerationPending
|
||||
self.createdAt = chat.createdAt
|
||||
self.updatedAt = chat.updatedAt
|
||||
self.starred = chat.starred
|
||||
self.starredAt = chat.starredAt
|
||||
self.initiatedProvider = chat.initiatedProvider
|
||||
self.initiatedModel = chat.initiatedModel
|
||||
self.lastUsedProvider = chat.lastUsedProvider
|
||||
self.lastUsedModel = chat.lastUsedModel
|
||||
}
|
||||
|
||||
public init(search: SearchSummary) {
|
||||
self.type = .search
|
||||
self.id = search.id
|
||||
self.title = search.title
|
||||
self.query = search.query
|
||||
self.parentChatId = nil
|
||||
self.titleGenerationPending = false
|
||||
self.createdAt = search.createdAt
|
||||
self.updatedAt = search.updatedAt
|
||||
self.starred = search.starred
|
||||
self.starredAt = search.starredAt
|
||||
self.initiatedProvider = nil
|
||||
self.initiatedModel = nil
|
||||
self.lastUsedProvider = nil
|
||||
self.lastUsedModel = nil
|
||||
}
|
||||
|
||||
public var chatSummary: ChatSummary? {
|
||||
guard type == .chat else { return nil }
|
||||
return ChatSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
parentChatId: parentChatId,
|
||||
titleGenerationPending: titleGenerationPending,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
starredAt: starredAt,
|
||||
initiatedProvider: initiatedProvider,
|
||||
initiatedModel: initiatedModel,
|
||||
lastUsedProvider: lastUsedProvider,
|
||||
lastUsedModel: lastUsedModel
|
||||
)
|
||||
}
|
||||
|
||||
public var searchSummary: SearchSummary? {
|
||||
guard type == .search else { return nil }
|
||||
return SearchSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
query: query,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
starredAt: starredAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkspaceItem {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case id
|
||||
case title
|
||||
case query
|
||||
case searchSnippet
|
||||
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)
|
||||
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
|
||||
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 {
|
||||
@@ -191,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
toolName: object["toolName"]?.stringValue ?? name,
|
||||
status: object["status"]?.stringValue,
|
||||
summary: object["summary"]?.stringValue,
|
||||
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||
durationMs: object["durationMs"]?.numberValue.flatMap { value in
|
||||
value >= 0 && value < Double(Int.max) ? Int(value) : nil
|
||||
},
|
||||
error: object["error"]?.stringValue,
|
||||
resultPreview: object["resultPreview"]?.stringValue
|
||||
resultPreview: object["resultPreview"]?.stringValue,
|
||||
args: object["args"],
|
||||
result: object["result"],
|
||||
startedAt: object["startedAt"]?.stringValue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
public var args: JSONValue? = nil
|
||||
public var result: JSONValue? = nil
|
||||
public var startedAt: String? = nil
|
||||
}
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
@@ -306,8 +482,12 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
@@ -315,6 +495,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var messages: [Message]
|
||||
}
|
||||
|
||||
extension ChatDetail {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
case messages
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
messages = try container.decode([Message].self, forKey: .messages)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var createdAt: Date
|
||||
@@ -346,6 +561,8 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var requestId: String?
|
||||
public var latencyMs: Int?
|
||||
public var error: String?
|
||||
@@ -427,8 +644,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
|
||||
public var summary: String
|
||||
public var args: [String: JSONValue]
|
||||
public var startedAt: String
|
||||
public var completedAt: String
|
||||
public var durationMs: Int
|
||||
public var completedAt: String?
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
}
|
||||
@@ -524,6 +741,15 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceSearchResponse: Codable, Sendable {
|
||||
var items: [WorkspaceItem]
|
||||
var hasMore: Bool
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
struct ChatDetailResponse: Codable {
|
||||
var chat: ChatDetail
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
enum PhoneRoute: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case draftChat
|
||||
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .settings:
|
||||
return .settings
|
||||
}
|
||||
@@ -64,6 +66,7 @@ struct SybilPhoneShellView: View {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
@@ -87,6 +90,10 @@ struct SybilPhoneShellView: View {
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
@@ -130,7 +137,10 @@ struct SybilPhoneShellView: View {
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay
|
||||
onShowSidebar: showSidebarOverlay,
|
||||
onOpenForkedChat: { chatID in
|
||||
showRoute(.chat(chatID))
|
||||
}
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
@@ -239,6 +249,7 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||
dismissSidebarKeyboard()
|
||||
let update = {
|
||||
route = nextRoute
|
||||
}
|
||||
@@ -268,12 +279,19 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func hideSidebarOverlay() {
|
||||
dismissSidebarKeyboard()
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func dismissSidebarKeyboard() {
|
||||
if isSidebarOverlayPresented {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||
return
|
||||
@@ -511,7 +529,12 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
}
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
},
|
||||
onSelectAgent: { onSelect(.agent) },
|
||||
isAgentSelected: highlightedSelection == .agent
|
||||
)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -585,6 +608,7 @@ private struct SybilPhoneDestinationView: View {
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
let onOpenForkedChat: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
@@ -593,7 +617,8 @@ private struct SybilPhoneDestinationView: View {
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat
|
||||
onRequestNewChat: onRequestNewChat,
|
||||
onOpenForkedChat: onOpenForkedChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task(id: route) {
|
||||
@@ -613,6 +638,8 @@ private struct SybilPhoneDestinationView: View {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .agent:
|
||||
viewModel.select(.agent)
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
case .draftSearch:
|
||||
|
||||
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var promptSelection: TextSelection?
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
let prompt = viewModel.quickQuestionPrompt
|
||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +62,11 @@ struct SybilQuickQuestionView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if hasAnswerContent {
|
||||
ForEach(viewModel.quickQuestionMessages) { message in
|
||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||
QuickQuestionMessageView(
|
||||
message: message,
|
||||
isSending: viewModel.isQuickQuestionSending,
|
||||
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
|
||||
)
|
||||
}
|
||||
|
||||
if let error = viewModel.quickQuestionError {
|
||||
@@ -94,13 +101,13 @@ struct SybilQuickQuestionView: View {
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
axis: .vertical
|
||||
selection: $promptSelection,
|
||||
axis: .horizontal
|
||||
)
|
||||
.focused($promptFocused)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submitQuestion)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -219,6 +226,11 @@ struct SybilQuickQuestionView: View {
|
||||
}
|
||||
|
||||
private func submitQuestion() {
|
||||
guard viewModel.canSendQuickQuestion else {
|
||||
return
|
||||
}
|
||||
|
||||
promptFocused = false
|
||||
_ = viewModel.sendQuickQuestion()
|
||||
}
|
||||
}
|
||||
@@ -254,11 +266,11 @@ private struct QuickQuestionPickerPill: View {
|
||||
private struct QuickQuestionMessageView: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -267,15 +279,12 @@ private struct QuickQuestionMessageView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.font(.body)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,8 @@ struct SybilSearchResultsView: View {
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 20 + topContentInset)
|
||||
.padding(.bottom, 20 + bottomContentInset)
|
||||
|
||||
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
|
||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||
}
|
||||
|
||||
@@ -44,16 +46,18 @@ final class SybilSettingsStore {
|
||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
|
||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||
]
|
||||
self.preferredModelByProvider = preferredModels
|
||||
|
||||
self.quickQuestionPreferredProvider =
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
self.quickQuestionPreferredModelByProvider = [
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-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",
|
||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||
.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[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||
|
||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSidebarView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -111,41 +112,174 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
var onSelectAgent: (() -> Void)? = nil
|
||||
var isAgentSelected: Bool? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
renameTarget != nil
|
||||
} set: { isPresented in
|
||||
if !isPresented {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
AgentAvatar()
|
||||
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
|
||||
if viewModel.agent.unreadCount > 0 {
|
||||
Text(String(viewModel.agent.unreadCount))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(SybilTheme.primary, in: Capsule())
|
||||
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(12)
|
||||
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("agent-sidebar")
|
||||
.padding(.horizontal, 10).padding(.top, 10)
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
TextField("Search chats and web searches", text: Binding(
|
||||
get: { viewModel.sidebarSearchQuery },
|
||||
set: { viewModel.setSidebarSearchQuery($0) }
|
||||
))
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.focused($isSearchFocused)
|
||||
.accessibilityLabel("Search conversation history and saved web searches")
|
||||
.onSubmit {
|
||||
isSearchFocused = false
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
|
||||
if !viewModel.sidebarSearchQuery.isEmpty {
|
||||
Button {
|
||||
viewModel.setSidebarSearchQuery("")
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear history search")
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
|
||||
.padding(10)
|
||||
|
||||
itemList
|
||||
}
|
||||
}
|
||||
|
||||
private var itemList: some View {
|
||||
Group {
|
||||
if viewModel.isSearchingSidebar ||
|
||||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
|
||||
VStack(spacing: 12) {
|
||||
Text(error)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Try again") {
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else if viewModel.visibleSidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if viewModel.isSidebarSearchActive {
|
||||
Text("Try a title, a query, or words from a conversation.")
|
||||
.font(.sybil(.caption))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
ForEach(viewModel.visibleSidebarItems) { item in
|
||||
Button {
|
||||
isSearchFocused = false
|
||||
onSelect(item)
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
||||
}
|
||||
} label: {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if case let .chat(chatID) = item.selection {
|
||||
Button {
|
||||
forkChat(item)
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = chatID
|
||||
} label: {
|
||||
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
@@ -155,14 +289,61 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
|
||||
Text("Showing the 100 most recent matches. Refine your search to find more.")
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||
TextField("Title", text: $renameTitle)
|
||||
Button("Cancel", role: .cancel) {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
Button("Save") {
|
||||
let target = renameTarget
|
||||
let title = renameTitle
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
|
||||
if let target, case let .chat(chatID) = target.selection {
|
||||
Task {
|
||||
await viewModel.renameChat(chatID: chatID, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func forkChat(_ item: SidebarItem) {
|
||||
guard case let .chat(chatID) = item.selection else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
let draftKindAtStart = viewModel.draftKind
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == draftKindAtStart else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +352,25 @@ struct SybilSidebarItemList: View {
|
||||
struct SybilSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
var isSelected: Bool
|
||||
var searchQuery = ""
|
||||
|
||||
private func highlightedSnippet(_ snippet: String) -> AttributedString {
|
||||
var highlighted = AttributedString(snippet)
|
||||
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
|
||||
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
|
||||
guard let range = Range(match.range, in: searchQuery) else { return nil }
|
||||
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
}.filter { !$0.isEmpty }
|
||||
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
|
||||
guard let range = Range(word.range, in: snippet) else { continue }
|
||||
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
guard terms.contains(where: { normalized.hasPrefix($0) }),
|
||||
let start = AttributedString.Index(range.lowerBound, within: highlighted),
|
||||
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
|
||||
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
|
||||
}
|
||||
return highlighted
|
||||
}
|
||||
|
||||
private var isHighlighted: Bool {
|
||||
isSelected
|
||||
@@ -204,6 +404,12 @@ struct SybilSidebarRow: View {
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
if item.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if item.isRunning {
|
||||
@@ -226,6 +432,15 @@ struct SybilSidebarRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
if let snippet = item.searchSnippet, !snippet.isEmpty {
|
||||
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -64,6 +64,10 @@ extension Font {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilLayout {
|
||||
static let webContentMaxWidth: CGFloat = 896
|
||||
}
|
||||
|
||||
enum SybilTheme {
|
||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||
@@ -78,6 +82,7 @@ enum SybilTheme {
|
||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
|
||||
|
||||
@MainActor static func applySystemAppearance() {
|
||||
let navAppearance = UINavigationBarAppearance()
|
||||
@@ -178,8 +183,19 @@ enum SybilTheme {
|
||||
static var toolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
|
||||
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
|
||||
Color(red: 0.01, green: 0.15, blue: 0.17),
|
||||
Color(red: 0.03, green: 0.09, blue: 0.15)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
static var runningToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.30, green: 0.19, blue: 0.04),
|
||||
Color(red: 0.09, green: 0.05, blue: 0.17)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
@@ -189,8 +205,8 @@ enum SybilTheme {
|
||||
static var failedToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
danger.opacity(0.18),
|
||||
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
|
||||
Color(red: 0.27, green: 0.04, blue: 0.10),
|
||||
Color(red: 0.15, green: 0.03, blue: 0.07)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import ImageIO
|
||||
import Observation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
@@ -19,6 +18,7 @@ struct SybilWorkspaceView: View {
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@@ -26,10 +26,6 @@ struct SybilWorkspaceView: View {
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptViewportHeight: CGFloat = 0
|
||||
@State private var pendingAssistantBaselineHeight: CGFloat?
|
||||
@State private var newChatSwipeOffset: CGFloat = 0
|
||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||
@@ -42,10 +38,6 @@ struct SybilWorkspaceView: View {
|
||||
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||
private let composerOverlayContentInset: CGFloat = 112
|
||||
|
||||
private var visibleTranscriptTailSpacerHeight: CGFloat {
|
||||
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
|
||||
}
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -153,17 +145,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.onChange(of: transcriptScrollContextID) { _, _ in
|
||||
handleTranscriptContextChange()
|
||||
}
|
||||
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
|
||||
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if message != nil && !viewModel.isSendingVisibleChat {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
}
|
||||
.task(id: composerFocusPolicyID) {
|
||||
await applyComposerFocusPolicy()
|
||||
}
|
||||
@@ -174,8 +155,6 @@ struct SybilWorkspaceView: View {
|
||||
workspaceContentStack
|
||||
|
||||
if showsCustomWorkspaceNavigation {
|
||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
||||
.allowsHitTesting(false)
|
||||
customWorkspaceNavigationBar
|
||||
}
|
||||
}
|
||||
@@ -194,6 +173,8 @@ struct SybilWorkspaceView: View {
|
||||
if isSettingsSelected {
|
||||
SybilSettingsView(viewModel: viewModel)
|
||||
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.selectedItem == .agent {
|
||||
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.isSearchMode {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.displayedSearch,
|
||||
@@ -212,15 +193,12 @@ struct SybilWorkspaceView: View {
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||
onViewportHeightChange: { height in
|
||||
handleTranscriptViewportHeightChange(height)
|
||||
},
|
||||
onPendingAssistantHeightChange: { height in
|
||||
handlePendingAssistantHeightChange(height)
|
||||
}
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||
isForking: viewModel.isForkingChat,
|
||||
onForkResponse: forkResponse
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -254,17 +232,33 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func forkResponse(messageID: String) {
|
||||
guard viewModel.draftKind == nil,
|
||||
case let .chat(chatID) = viewModel.selectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
customWorkspaceNavigationTitle
|
||||
|
||||
workspaceNavigationTrailingControl
|
||||
}
|
||||
@@ -277,6 +271,32 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedProviderModelSubtitle: String {
|
||||
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedModel.isEmpty else {
|
||||
return viewModel.provider.displayName
|
||||
}
|
||||
return "\(viewModel.provider.displayName) • \(selectedModel)"
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationTitle: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
|
||||
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
|
||||
.font(.sybil(size: 10, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationLeadingControl: some View {
|
||||
switch navigationLeadingControl {
|
||||
@@ -311,86 +331,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptContextChange() {
|
||||
resetTranscriptTailSpacer(animated: false)
|
||||
}
|
||||
|
||||
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
|
||||
guard !viewModel.isSearchMode else {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if isSending {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if wasSending {
|
||||
if viewModel.errorMessage != nil {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
pendingAssistantBaselineHeight = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
|
||||
transcriptViewportHeight = height
|
||||
|
||||
if viewModel.isSendingVisibleChat,
|
||||
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
|
||||
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
if pendingAssistantBaselineHeight == nil {
|
||||
pendingAssistantBaselineHeight = height
|
||||
}
|
||||
|
||||
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: transcriptTailSpacerTargetHeight,
|
||||
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
|
||||
currentAssistantHeight: height
|
||||
)
|
||||
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
|
||||
setTranscriptTailSpacer(nextHeight, animated: false)
|
||||
}
|
||||
|
||||
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
|
||||
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
|
||||
transcriptTailSpacerTargetHeight = targetHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(targetHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func resetTranscriptTailSpacer(animated: Bool) {
|
||||
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
|
||||
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
|
||||
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = {
|
||||
transcriptTailSpacerHeight = nextHeight
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.22), update)
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
@@ -569,7 +509,7 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationTrailingControl: some View {
|
||||
if isSettingsSelected {
|
||||
if isSettingsSelected || viewModel.selectedItem == .agent {
|
||||
EmptyView()
|
||||
} else if viewModel.isSearchMode {
|
||||
searchModeNavigationLabel
|
||||
@@ -686,6 +626,7 @@ struct SybilWorkspaceView: View {
|
||||
.onSubmit {
|
||||
submitComposer()
|
||||
}
|
||||
.submitOnMacReturn(submitComposer)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
@@ -808,14 +749,8 @@ struct SybilWorkspaceView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !viewModel.isSearchMode {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
}
|
||||
#endif
|
||||
|
||||
Task {
|
||||
@@ -881,37 +816,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilTranscriptTailSpacer {
|
||||
static let minimumHeight: CGFloat = 20
|
||||
static let replyBufferMin: CGFloat = 288
|
||||
static let replyBufferMax: CGFloat = 576
|
||||
static let replyBufferViewportRatio: CGFloat = 0.52
|
||||
|
||||
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
|
||||
guard viewportHeight > 0 else {
|
||||
return replyBufferMin
|
||||
}
|
||||
|
||||
return min(
|
||||
replyBufferMax,
|
||||
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
|
||||
)
|
||||
}
|
||||
|
||||
static func clampedHeight(_ height: CGFloat) -> CGFloat {
|
||||
max(minimumHeight, height.rounded(.up))
|
||||
}
|
||||
|
||||
static func placeholderHeight(
|
||||
targetHeight: CGFloat,
|
||||
baselineAssistantHeight: CGFloat,
|
||||
currentAssistantHeight: CGFloat
|
||||
) -> CGFloat {
|
||||
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
|
||||
return clampedHeight(targetHeight - consumedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatSwipeMetrics {
|
||||
static let referenceWidth: CGFloat = 390
|
||||
static let horizontalActivationDistance: CGFloat = 18
|
||||
@@ -1470,173 +1374,6 @@ private struct SybilNavigationFadeBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilWorkspaceCharacterBackdrop: View {
|
||||
var isBusy: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.13),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 10,
|
||||
endRadius: 150
|
||||
)
|
||||
.frame(width: 136, height: 118)
|
||||
.blur(radius: 7)
|
||||
.offset(x: 28, y: -24)
|
||||
|
||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
||||
.frame(width: 172, height: 172)
|
||||
.opacity(0.92)
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black,
|
||||
Color.black,
|
||||
Color.black.opacity(0)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.black.opacity(0.98),
|
||||
Color.black
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
.offset(x: 8, y: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
||||
var resourceName: String
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIImageView {
|
||||
let imageView = SybilAnimatedUIImageView()
|
||||
imageView.backgroundColor = .clear
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = false
|
||||
imageView.isOpaque = false
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
return imageView
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
||||
guard context.coordinator.resourceName != resourceName else {
|
||||
if !imageView.isAnimating {
|
||||
imageView.startAnimating()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
context.coordinator.resourceName = resourceName
|
||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
||||
imageView.startAnimating()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var resourceName: String?
|
||||
}
|
||||
}
|
||||
|
||||
private final class SybilAnimatedUIImageView: UIImageView {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum SybilAnimatedGIFCache {
|
||||
private static var images: [String: UIImage] = [:]
|
||||
|
||||
static func image(named name: String) -> UIImage? {
|
||||
if let cached = images[name] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let image = loadImage(named: name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
images[name] = image
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
private static func loadImage(named name: String) -> UIImage? {
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
||||
|
||||
guard let url,
|
||||
let data = try? Data(contentsOf: url),
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frameCount = CGImageSourceGetCount(source)
|
||||
guard frameCount > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var frames: [UIImage] = []
|
||||
frames.reserveCapacity(frameCount)
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
for index in 0 ..< frameCount {
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
||||
continue
|
||||
}
|
||||
frames.append(UIImage(cgImage: cgImage))
|
||||
duration += frameDuration(at: index, source: source)
|
||||
}
|
||||
|
||||
guard !frames.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if frames.count == 1 {
|
||||
return frames[0]
|
||||
}
|
||||
|
||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
||||
}
|
||||
|
||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
||||
else {
|
||||
return 0.08
|
||||
}
|
||||
|
||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
||||
return max(delay, 0.03)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
|
||||
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
|
||||
}
|
||||
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
|
||||
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
|
||||
}
|
||||
|
||||
private final class MemoryOutbox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var rows: [AgentOutboxRow] = []
|
||||
var storage: AgentOutboxStorage {
|
||||
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
|
||||
}
|
||||
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
|
||||
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
|
||||
}
|
||||
private actor ControlledAgentSocket: AgentSocket {
|
||||
let stream: AsyncThrowingStream<Data, any Error>
|
||||
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
|
||||
private(set) var closed = false
|
||||
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
|
||||
func receive() async throws -> Data {
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
guard let data = try await iterator.next() else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
func send(_ text: String) {}
|
||||
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
|
||||
func closeCode() -> Int { 0 }
|
||||
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
|
||||
}
|
||||
private actor TestAgentClient: AgentAPIClienting {
|
||||
var pages: [String: AgentPage]
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
var socketAfters: [String] = []
|
||||
var sockets: [ControlledAgentSocket] = []
|
||||
var holdRead = false
|
||||
var heldRead: CheckedContinuation<AgentReadState, Never>?
|
||||
var beforeSubmit: (@Sendable () -> Bool)?
|
||||
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
|
||||
func agentDescriptor() -> AgentDescriptor {
|
||||
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
|
||||
}
|
||||
func agentMessages(after: String?, before: String?) -> AgentPage {
|
||||
let cursor = after ?? "0"; requests.append(cursor)
|
||||
return pages[cursor] ?? agentPage([], cursor: cursor)
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
|
||||
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
|
||||
}
|
||||
func agentSocket(after: String) async -> any AgentSocket {
|
||||
socketAfters.append(after)
|
||||
let socket = ControlledAgentSocket(); sockets.append(socket)
|
||||
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
|
||||
return socket
|
||||
}
|
||||
func connectedAfters() -> [String] { socketAfters }
|
||||
func latestSocket() -> ControlledAgentSocket? { sockets.last }
|
||||
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
|
||||
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
|
||||
func submittedIDs() -> [String] { submissions.map(\.1) }
|
||||
func requestedCursors() -> [String] { requests }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func eventually(_ predicate: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 {
|
||||
if predicate() { return true }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
struct AgentTests {
|
||||
@Test func cursorAndMalformedFrames() throws {
|
||||
#expect(try AgentCursor.normalize("00012") == "12")
|
||||
#expect(try AgentCursor.advance("9", "10") == "10")
|
||||
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
|
||||
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
|
||||
let original = state
|
||||
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
|
||||
#expect(state == original)
|
||||
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
|
||||
#expect(state.cursor == "10")
|
||||
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
|
||||
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
|
||||
}
|
||||
|
||||
@Test func terminalToolAndRestSocketRace() throws {
|
||||
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
|
||||
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
|
||||
end.createdAt.addTimeInterval(5)
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
#expect(state.cursor == "9")
|
||||
#expect(state.messages.count == 2)
|
||||
let tool = try #require(state.messages.first?.toolCallMetadata)
|
||||
#expect(tool.status == "failed")
|
||||
#expect(tool.error == "fixture failure")
|
||||
#expect(tool.args == .object(["query": .string("fixture")]))
|
||||
#expect(tool.startedAt == "start")
|
||||
#expect(state.messages.first?.createdAt == start.createdAt)
|
||||
}
|
||||
|
||||
@Test func unreadExcludesToolsAndUsers() throws {
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
|
||||
state.readCursor = "9"
|
||||
#expect(state.unreadCount == 1)
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
|
||||
#expect(state.unreadCount == 0)
|
||||
}
|
||||
|
||||
@Test func typingReplyAndStaleActivityAreAtomic() throws {
|
||||
var presentation = AgentPresentation(), state = AgentRoomState()
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
|
||||
var after = state; after.activity = busy
|
||||
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
|
||||
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
|
||||
try after.apply(reply)
|
||||
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
|
||||
#expect(state.activity.isBusy)
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
|
||||
for name in AgentPresentation.quietTools {
|
||||
busy.tools.append(.init(id: name, name: name, status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
|
||||
}
|
||||
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
||||
}
|
||||
|
||||
@Test func socketURLAndAuthentication() throws {
|
||||
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
||||
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
|
||||
#expect(frame.objectValue?["token"] == .string("token"))
|
||||
#expect(frame.objectValue?["after"] == .string("9"))
|
||||
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
|
||||
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
|
||||
#expect(openFrame.objectValue?["token"] == .null)
|
||||
}
|
||||
|
||||
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(await client.requestedCursors() == ["0", "3", "7"])
|
||||
#expect(room.snapshot.room.cursor == "8")
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs == ["late"])
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
|
||||
let memory = MemoryOutbox(), client = TestAgentClient()
|
||||
await client.setFailures(submit: true)
|
||||
await client.requirePersisted { !memory.read().isEmpty }
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(room.send("identical")); #expect(room.send("identical"))
|
||||
#expect(room.messages.count == 2)
|
||||
let first = room.outbox[0], second = room.outbox[1]
|
||||
#expect(first.requestID != second.requestID)
|
||||
#expect(memory.read().count == 2)
|
||||
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
|
||||
room.flush()
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
#expect(room.outbox[0].status == "complete")
|
||||
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
|
||||
room.retry(second.id)
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
let ids = await client.submittedIDs()
|
||||
#expect(ids.filter { $0 == second.requestID }.count == 2)
|
||||
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
|
||||
defer { restored.stop() }
|
||||
#expect(restored.outbox.count == 1)
|
||||
#expect(restored.outbox[0].requestID == second.requestID)
|
||||
#expect(restored.outbox[0].createdAt == second.createdAt)
|
||||
}
|
||||
|
||||
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
|
||||
let client = TestAgentClient(); await client.setFailures(malformed: true)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.send("fixture")
|
||||
#expect(await eventually { room.outbox.first?.status == "failed" })
|
||||
#expect(room.outbox.first?.seenOnServer == false)
|
||||
let failed = SybilAgentRoom()
|
||||
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
|
||||
defer { failed.stop() }
|
||||
failed.send("must not submit")
|
||||
#expect(failed.outbox.first?.status == "failed")
|
||||
#expect(await client.submittedIDs().count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.isSelected = true
|
||||
await client.setFailures(read: true)
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.error != nil })
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
await client.setFailures()
|
||||
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
#expect(room.unreadCount == 1)
|
||||
room.isObscured = true; room.markRead(renderedCursor: "2")
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
#expect(room.snapshot.room.readCursor == "1")
|
||||
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
|
||||
#expect(room.unreadCount == 0)
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.send("interrupted fixture")
|
||||
let original = try #require(room.outbox.first)
|
||||
room.setActive(false)
|
||||
#expect(room.outbox.first?.status == "failed")
|
||||
#expect(memory.read().first?.requestID == original.requestID)
|
||||
room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.retry(original.id)
|
||||
#expect(await eventually { room.outbox.first?.seenOnServer == true })
|
||||
#expect(room.outbox.first?.createdAt == original.createdAt)
|
||||
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
|
||||
}
|
||||
|
||||
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
|
||||
let old = TestAgentClient(); await old.pauseRead()
|
||||
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
|
||||
for _ in 0..<100 {
|
||||
if await old.hasHeldRead() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
room.disconnect()
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
|
||||
room.configure(client: next, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
await old.releaseRead()
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
try await room.catchUp(client: next)
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
#expect(await next.requestedCursors() == ["0", "2"])
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
|
||||
let client = TestAgentClient()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let socket = try #require(await client.latestSocket())
|
||||
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
|
||||
await socket.push(frame)
|
||||
#expect(await eventually { room.snapshot.room.cursor == "7" })
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let next = try #require(await client.latestSocket())
|
||||
await next.push(frame)
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
#expect(await client.connectedAfters() == ["0", "7"])
|
||||
#expect(room.messages.count == 1)
|
||||
#expect(await socket.closed)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+149
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
|
||||
The conversation sidebar includes **Search chats and web searches** on iPhone,
|
||||
iPad, and Mac. It searches all saved history by title, conversation text, and saved
|
||||
web-search query/answer, with single-line excerpts that bold matching words and
|
||||
truncate in the middle. Search waits briefly while typing,
|
||||
cancels superseded requests, and leaves the current conversation open. Clear the
|
||||
field to return to recent items. Searches use word prefixes (for example, `plan`
|
||||
finds `planning`); if more than 100 items match, refine the query.
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
The permanent **Sybil Agent** sidebar item opens the persistent room on both
|
||||
phone and iPad. It uses the configured Sybil API URL (including an explicit
|
||||
`/api` path) and token. The room stays subscribed while other chats are selected;
|
||||
foreground and network recovery replay from the last merged cursor. Shared unread
|
||||
counts include only public assistant replies and are acknowledged after the
|
||||
visible transcript reaches the bottom and its entrance animation finishes.
|
||||
|
||||
Multiple messages can be queued while the Agent works. The local outbox is saved
|
||||
atomically under Application Support before submission and is scoped to the API
|
||||
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
|
||||
its request ID and timestamp across relaunch. A canonical server echo confirms it
|
||||
even if the HTTP acknowledgement is lost. Tool cards expand to show input, result,
|
||||
and error details. Attachments and model selection belong to ordinary chats.
|
||||
|
||||
Agent contracts remain in `../docs/api/rest.md` and
|
||||
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
|
||||
|
||||
### Agent regression and Simulator checks
|
||||
|
||||
Run these commands from this worktree's `ios/` directory:
|
||||
|
||||
```sh
|
||||
just build
|
||||
just test
|
||||
just run
|
||||
just screenshot build/agent-evidence/simulator.png
|
||||
```
|
||||
|
||||
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
|
||||
regressions along with the existing app tests through Xcode on iPhone 16e.
|
||||
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
|
||||
|
||||
```sh
|
||||
python3 Tests/Fixtures/agent_server.py
|
||||
```
|
||||
|
||||
In a second terminal, run:
|
||||
|
||||
```sh
|
||||
just generate
|
||||
xcodebuild test -scheme SybilAgentUI \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||
-parallel-testing-enabled NO \
|
||||
-resultBundlePath build/agent-evidence/phone-ui.xcresult
|
||||
```
|
||||
|
||||
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
|
||||
external services. UI tests exercise the real REST/WebSocket client and retain
|
||||
screenshots in the result bundle. They cover queued submissions, uncertain
|
||||
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
|
||||
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
|
||||
anchors and multiline keyboard layout. The launch arguments select the fixture
|
||||
through ordinary API settings; no fixture mode is compiled into the application.
|
||||
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
|
||||
Simulator Reduce Motion enabled before release.
|
||||
|
||||
## 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.
|
||||
|
||||
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||
Question, while the normal chat/search composer continues to use Return for a
|
||||
new line.
|
||||
|
||||
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. On iOS, verify Return submits Quick Question while Return in the normal
|
||||
prompt adds a newline. 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.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only deterministic Agent contract fixture. Never contacts external services.
|
||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
from aiohttp import web
|
||||
|
||||
sockets = set()
|
||||
messages = []
|
||||
requests = {}
|
||||
read_cursor = 0
|
||||
cursor = 0
|
||||
busy = False
|
||||
attempts = {}
|
||||
log = []
|
||||
reply_tasks = set()
|
||||
|
||||
def stamp():
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
def activity():
|
||||
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
|
||||
backgroundWorkers=0, tools=[], queuedMessages=[])
|
||||
|
||||
def message(content, role='assistant', id=None, info=None):
|
||||
global cursor
|
||||
cursor += 1
|
||||
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
|
||||
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
|
||||
messages.append(row)
|
||||
return row
|
||||
|
||||
def page(rows, position=None, more=False):
|
||||
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
|
||||
|
||||
async def broadcast(frame):
|
||||
for socket in list(sockets):
|
||||
try:
|
||||
await socket.send_json(frame)
|
||||
except ConnectionError:
|
||||
sockets.discard(socket)
|
||||
|
||||
async def emit(content):
|
||||
row = message(content)
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
return row
|
||||
|
||||
async def reply(content):
|
||||
global busy, cursor
|
||||
await asyncio.sleep(1.2)
|
||||
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
|
||||
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
|
||||
await broadcast(dict(type='messages', **page([tool])))
|
||||
await asyncio.sleep(0.8)
|
||||
cursor += 1
|
||||
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
|
||||
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
|
||||
await broadcast(dict(type='messages', **page([tool])))
|
||||
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
|
||||
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
|
||||
await broadcast(dict(type='messages', **page([failed])))
|
||||
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
|
||||
# Deliberately stale busy frame after the public reply.
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
await asyncio.sleep(0.5)
|
||||
busy = False
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
body = await request.json()
|
||||
action = body.get('action')
|
||||
if action == 'reset':
|
||||
for task in reply_tasks:
|
||||
task.cancel()
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'reply':
|
||||
await emit(body.get('content', 'Late asynchronous fixture reply'))
|
||||
elif action == 'long-tool':
|
||||
row = message('Local tool output', 'tool')
|
||||
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
|
||||
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'busy':
|
||||
busy = body.get('value', True)
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
elif action == 'read':
|
||||
read_cursor = cursor
|
||||
elif action == 'disconnect':
|
||||
for socket in list(sockets):
|
||||
await socket.close(code=1012)
|
||||
elif action == 'malformed':
|
||||
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
if path == '/api/v1/auth/session':
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
if path == '/api/v1/workspace-items':
|
||||
return web.json_response(dict(items=[], hasMore=False))
|
||||
if path == '/api/v1/chats':
|
||||
return web.json_response(dict(chats=[]))
|
||||
if path == '/api/v1/searches':
|
||||
return web.json_response(dict(searches=[]))
|
||||
if path == '/api/v1/models':
|
||||
return web.json_response(dict(providers={}))
|
||||
if path == '/api/v1/active-runs':
|
||||
return web.json_response(dict(chats=[], searches=[]))
|
||||
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
|
||||
if request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(error='Fixture token missing'), status=401)
|
||||
if path == '/api/v1/agent':
|
||||
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
||||
if path == '/api/v1/agent/messages':
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
key, content = body['requestId'], body['content']
|
||||
attempts[key] = attempts.get(key, 0) + 1
|
||||
if content == 'retry fixture' and attempts[key] == 1:
|
||||
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
|
||||
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
|
||||
if key not in requests:
|
||||
requests[key] = receipt
|
||||
row = message(content, 'user', id=receipt['messageId'])
|
||||
busy = True
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
task = asyncio.create_task(reply(content))
|
||||
reply_tasks.add(task)
|
||||
task.add_done_callback(reply_tasks.discard)
|
||||
if content == 'echo wins fixture':
|
||||
await asyncio.sleep(0.3)
|
||||
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
|
||||
return web.json_response(requests[key], status=202)
|
||||
after = int(request.query.get('after', 0))
|
||||
rows = [m for m in messages if m['metadata']['entryId'] > after]
|
||||
limit = min(int(request.query.get('limit', 200)), 200)
|
||||
selected = rows[:limit]
|
||||
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
|
||||
if path == '/api/v1/agent/ws':
|
||||
if request.query:
|
||||
return web.Response(status=400)
|
||||
socket = web.WebSocketResponse(); await socket.prepare(request)
|
||||
auth = await socket.receive_json(timeout=5)
|
||||
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
|
||||
await socket.close(code=4401); return socket
|
||||
log.append(dict(authAfter=auth.get('after')))
|
||||
sockets.add(socket)
|
||||
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
|
||||
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
|
||||
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
try:
|
||||
async for frame in socket:
|
||||
if frame.type == web.WSMsgType.TEXT:
|
||||
data = json.loads(frame.data)
|
||||
if data.get('type') == 'ping':
|
||||
await socket.send_json(dict(type='pong'))
|
||||
finally:
|
||||
sockets.discard(socket)
|
||||
return socket
|
||||
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
|
||||
|
||||
for i in range(3):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
app = web.Application()
|
||||
app.router.add_route('*', '/{path:.*}', route)
|
||||
web.run_app(app, host='127.0.0.1', port=8799)
|
||||
@@ -0,0 +1,175 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class AgentUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
try await control(["action": "reset", "count": 3])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
openAgent()
|
||||
}
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists)
|
||||
}
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
private func send(_ text: String) {
|
||||
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
func testSendQueueToolsAndRetry() async throws {
|
||||
capture("agent-history")
|
||||
send("first fixture")
|
||||
send("second fixture")
|
||||
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-replies-keyboard")
|
||||
app.swipeDown()
|
||||
send("retry fixture")
|
||||
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
|
||||
capture("agent-retry")
|
||||
app.buttons["agent-retry"].tap()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
send("echo wins fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
composer.tap(); app.swipeDown()
|
||||
let failures = app.buttons.matching(identifier: "agent-tool-fixture_error")
|
||||
failures.element(boundBy: failures.count - 1).tap()
|
||||
XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.staticTexts["agent-tool-error"].exists)
|
||||
capture("agent-tool-error")
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
||||
}
|
||||
func testReduceMotionSend() async throws {
|
||||
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
||||
send("reduce motion fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-reduce-motion")
|
||||
}
|
||||
|
||||
func testExpandedToolCollapseToShortHistory() async throws {
|
||||
try await control(["action": "long-tool"])
|
||||
let tool = app.buttons["agent-tool-fixture_long"]
|
||||
XCTAssertTrue(tool.waitForExistence(timeout: 5))
|
||||
tool.tap()
|
||||
XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3))
|
||||
let scroll = app.scrollViews.firstMatch
|
||||
for _ in 0..<12 {
|
||||
if tool.isHittable { break }
|
||||
scroll.swipeDown()
|
||||
}
|
||||
XCTAssertTrue(tool.isHittable)
|
||||
tool.tap()
|
||||
XCTAssertEqual(tool.value as? String, "Collapsed")
|
||||
try await control(["action": "reply", "content": "Reply after collapsing a tall tool"])
|
||||
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
capture("agent-collapsed-tool-short-history")
|
||||
}
|
||||
|
||||
func testTabletSidebarAndInactiveChat() async throws {
|
||||
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
|
||||
app.buttons["New chat"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "iPad inactive chat reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-ipad-sidebar")
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
|
||||
capture("agent-ipad-room-before-send")
|
||||
send("iPad fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
capture("agent-ipad-room")
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
func testScrollPreservationAndKeyboard() async throws {
|
||||
app.terminate()
|
||||
try await control(["action": "reset", "count": 160])
|
||||
app.launch(); openAgent()
|
||||
let scroll = app.scrollViews.firstMatch
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown()
|
||||
scroll.swipeDown()
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||
let before = anchor.frame.minY
|
||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
capture("agent-reading-anchor")
|
||||
for _ in 0..<25 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
app.buttons["Load earlier messages"].tap()
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
||||
capture("agent-older-history-anchor")
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
capture("agent-multiline-keyboard")
|
||||
}
|
||||
|
||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||
app.buttons["Show sidebar"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-unread-sidebar")
|
||||
let covered = try await control(["action": "state"])
|
||||
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
|
||||
try await control(["action": "read"])
|
||||
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
|
||||
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [cleared], timeout: 20)
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
|
||||
try await control(["action": "disconnect"])
|
||||
try await control(["action": "reply", "content": "Reconnect fixture reply"])
|
||||
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
|
||||
try await control(["action": "malformed"])
|
||||
try await control(["action": "reply", "content": "Recovered malformed frame"])
|
||||
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
|
||||
XCUIDevice.shared.press(.home)
|
||||
try await control(["action": "reply", "content": "Foreground fixture reply"])
|
||||
app.activate()
|
||||
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
|
||||
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
|
||||
capture("agent-foreground-replay")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("net.buzzert.sybil2")
|
||||
team_id("DQQH5H6GBD")
|
||||
@@ -0,0 +1,47 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build a release tag and upload it to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci
|
||||
|
||||
match(type: "appstore")
|
||||
|
||||
tag = ENV.fetch("GITHUB_REF_NAME")
|
||||
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
||||
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
|
||||
|
||||
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
|
||||
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
ios_dir = File.expand_path("..", __dir__)
|
||||
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
|
||||
contents = File.read(project_spec)
|
||||
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
|
||||
UI.user_error!("Could not find version settings in #{project_spec}")
|
||||
end
|
||||
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
|
||||
File.write(project_spec, contents)
|
||||
|
||||
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
|
||||
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_content: ENV.fetch("ASC_KEY"),
|
||||
is_key_content_base64: true
|
||||
)
|
||||
|
||||
build_app(
|
||||
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
||||
scheme: "Sybil"
|
||||
)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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")
|
||||
+15
-5
@@ -5,9 +5,11 @@ derived_data := "build/DerivedData"
|
||||
default:
|
||||
@just build
|
||||
|
||||
build:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
@@ -16,13 +18,21 @@ build:
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
|
||||
run:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
run: generate
|
||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||
xcrun simctl launch booted net.buzzert.sybil2
|
||||
|
||||
build-mac: generate
|
||||
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
run-mac: build-mac
|
||||
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
screenshot path="build/sybil-screenshot.png":
|
||||
mkdir -p "$(dirname '{{path}}')"
|
||||
xcrun simctl io booted screenshot '{{path}}'
|
||||
|
||||
@@ -7,3 +7,31 @@ packages:
|
||||
path: Packages/Sybil
|
||||
include:
|
||||
- Apps/Sybil/project.yml
|
||||
- Apps/SybilMacQuickQuestion/project.yml
|
||||
|
||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||
targets:
|
||||
SybilAgentUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- Tests/SybilUITests
|
||||
dependencies:
|
||||
- target: SybilApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TEST_TARGET_NAME: SybilApp
|
||||
SWIFT_VERSION: 6.0
|
||||
schemes:
|
||||
SybilAgentUI:
|
||||
build:
|
||||
targets:
|
||||
SybilApp: all
|
||||
SybilAgentUITests: [test]
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- SybilAgentUITests
|
||||
|
||||
Executable
+90
@@ -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"
|
||||
Executable
+357
@@ -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"
|
||||
+30
-4
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
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)
|
||||
|
||||
## Stack
|
||||
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `OPENAI_API_KEY`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `XAI_API_KEY`
|
||||
- `GEMINI_API_KEY`
|
||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||
- `EXA_API_KEY`
|
||||
- `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`)
|
||||
- `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_USER` (optional SSH user when host does not include one)
|
||||
- `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_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||
|
||||
## API
|
||||
@@ -104,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
||||
- User accounts + per-device API keys
|
||||
- Postgres support + migrations for prod
|
||||
- Attachments + embeddings + semantic search
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
```dotenv
|
||||
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
|
||||
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
|
||||
```
|
||||
|
||||
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
|
||||
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
|
||||
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
|
||||
`sybil-agent` resolves privately. Supply the key through a private server environment
|
||||
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
|
||||
`ADMIN_TOKEN` authentication for clients.
|
||||
|
||||
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
|
||||
same persistent root conversation across devices. Sybil Agent remains the source of
|
||||
truth for messages and background replies. Sybil stores only the shared,
|
||||
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
|
||||
its migration is applied automatically at startup. The bundled nginx configuration
|
||||
forwards WebSocket upgrades. See `docs/api/rest.md` and
|
||||
`docs/api/streaming-chat.md` at the repository root for the client contracts.
|
||||
|
||||
Generated
+33
@@ -22,10 +22,12 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -876,6 +878,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -2241,6 +2253,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'folder',
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"chatId" TEXT,
|
||||
"searchId" TEXT,
|
||||
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add normalized search query lookup key for cache/reuse behavior.
|
||||
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
|
||||
|
||||
UPDATE "Search"
|
||||
SET "queryNormalized" = lower(trim("query"))
|
||||
WHERE "query" IS NOT NULL AND trim("query") != '';
|
||||
|
||||
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
|
||||
@@ -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");
|
||||
@@ -0,0 +1,70 @@
|
||||
-- External-content indexes avoid storing a second copy of conversation text.
|
||||
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
|
||||
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
|
||||
title, content='Chat', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
-- Hidden system prompts and tool payloads are not searchable conversation text.
|
||||
CREATE VIEW "MessageSearchContent" AS
|
||||
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
|
||||
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
|
||||
content, content='MessageSearchContent', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
|
||||
title, query, answerText, content='Search', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
|
||||
WHEN old.title IS NOT new.title BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
|
||||
WHEN new.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
|
||||
WHEN old.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
|
||||
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
|
||||
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
|
||||
INSERT INTO "MessageSearch" (rowid, content)
|
||||
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
|
||||
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
|
||||
-- Index existing history during migration, then maintain it transactionally.
|
||||
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
|
||||
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
|
||||
|
||||
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "AgentReadState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"cursor" TEXT NOT NULL DEFAULT '0',
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
@@ -13,6 +13,7 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
gemini
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
@@ -27,6 +28,11 @@ enum SearchSource {
|
||||
exa
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
starred
|
||||
folder
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -37,6 +43,7 @@ model User {
|
||||
|
||||
chats Chat[]
|
||||
searches Search[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
@@ -45,19 +52,31 @@ model Chat {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
title String?
|
||||
titleGenerationPending Boolean @default(false)
|
||||
|
||||
initiatedProvider Provider?
|
||||
initiatedModel String?
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
// Forks always point directly to the single root chat, never to another fork.
|
||||
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
||||
parentChatId String?
|
||||
childChats Chat[] @relation("ChatForks")
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([parentChatId])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
model Message {
|
||||
@@ -111,6 +130,7 @@ model Search {
|
||||
|
||||
title String?
|
||||
query String?
|
||||
queryNormalized String?
|
||||
|
||||
source SearchSource @default(exa)
|
||||
|
||||
@@ -129,8 +149,10 @@ model Search {
|
||||
userId String?
|
||||
|
||||
results SearchResult[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([queryNormalized, updatedAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -156,3 +178,48 @@ model SearchResult {
|
||||
|
||||
@@index([searchId, rank])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
kind ProjectKind @default(folder)
|
||||
title String
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
|
||||
items ProjectItem[]
|
||||
|
||||
@@index([kind])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ProjectItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
|
||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
chatId String?
|
||||
|
||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
||||
searchId String?
|
||||
|
||||
@@unique([projectId, chatId])
|
||||
@@unique([projectId, searchId])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
// Shared personal read position for the external persistent Sybil Agent room.
|
||||
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
|
||||
.transform(value => String(Number(value)));
|
||||
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
|
||||
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
|
||||
|
||||
type AgentOptions = { baseUrl?: string; apiKey?: string };
|
||||
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
|
||||
return result.data;
|
||||
};
|
||||
|
||||
async function readState() {
|
||||
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
|
||||
}
|
||||
|
||||
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
|
||||
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
|
||||
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
|
||||
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
|
||||
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
|
||||
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
|
||||
let data: unknown;
|
||||
try { data = await response.json(); }
|
||||
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
|
||||
return { status: response.status, data };
|
||||
};
|
||||
|
||||
app.get('/v1/agent', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const result = await proxyJson('/api/conversations/root');
|
||||
reply.code(result.status);
|
||||
if (result.status !== 200) return result.data;
|
||||
return { ...result.data as object, websocket: '/v1/agent/ws' };
|
||||
});
|
||||
app.get('/v1/agent/messages', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const query = parse(PageQuery, req.query);
|
||||
const params = new URLSearchParams();
|
||||
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
|
||||
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const body = parse(MessageBody, req.body);
|
||||
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
const receipt = SubmissionReceipt.safeParse(result.data);
|
||||
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
|
||||
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
|
||||
}
|
||||
}
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.get('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
return readState();
|
||||
});
|
||||
app.put('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const { cursor } = parse(ReadBody, req.body);
|
||||
// One SQLite statement makes concurrent tabs monotonic. Never write an older
|
||||
// cursor over one saved by another connection between a read and an update.
|
||||
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
|
||||
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
|
||||
return readState();
|
||||
});
|
||||
|
||||
attachAgentSocket(app, baseUrl, apiKey);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
|
||||
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
||||
const cleanup = new Set<() => void>();
|
||||
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
let url: URL;
|
||||
try { url = new URL(request.url || '/', 'http://localhost'); }
|
||||
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
|
||||
// Credentials must never enter URLs and access logs.
|
||||
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
sockets.handleUpgrade(request, socket, head, browser => {
|
||||
let upstream: WebSocket | undefined;
|
||||
let authenticated = false, disposed = false, alive = true;
|
||||
const send = (value: unknown) => {
|
||||
if (browser.readyState !== WebSocket.OPEN) return;
|
||||
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
|
||||
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
|
||||
};
|
||||
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
|
||||
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
|
||||
browser.on('pong', () => { alive = true; });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
|
||||
upstream?.terminate(); browser.terminate();
|
||||
};
|
||||
cleanup.add(dispose);
|
||||
browser.on('close', dispose); browser.on('error', dispose);
|
||||
browser.on('message', (raw, isBinary) => {
|
||||
try {
|
||||
if (isBinary) throw new Error('JSON text frames are required');
|
||||
const frame = JSON.parse(raw.toString());
|
||||
if (!authenticated) {
|
||||
const auth = parse(AuthFrame, frame);
|
||||
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
|
||||
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
|
||||
send(data.toString());
|
||||
});
|
||||
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
|
||||
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
||||
const subscribe = parse(SubscribeFrame, frame);
|
||||
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
|
||||
upstream.send(JSON.stringify(subscribe));
|
||||
} catch (error) {
|
||||
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
|
||||
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
app.server.on('upgrade', upgrade);
|
||||
app.addHook('preClose', async () => {
|
||||
app.server.off('upgrade', upgrade);
|
||||
for (const stop of cleanup) stop();
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
sockets.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const CHROMIUM_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
|
||||
|
||||
export const FETCH_URL_ACCEPT =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
|
||||
|
||||
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
|
||||
return {
|
||||
"User-Agent": CHROMIUM_USER_AGENT,
|
||||
Accept: accept,
|
||||
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
|
||||
return {
|
||||
...buildBrowserLikeRequestHeaders(accept),
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
};
|
||||
}
|
||||
+13
-1
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
},
|
||||
z.enum(["exa", "searxng"]).default("exa")
|
||||
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||
);
|
||||
|
||||
const BooleanFlagSchema = z.preprocess((value) => {
|
||||
@@ -66,10 +66,14 @@ const EnvSchema = z.object({
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
XAI_API_KEY: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
|
||||
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||
@@ -99,6 +103,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) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
|
||||
+9
-2
@@ -5,7 +5,8 @@ import swaggerUI from "@fastify/swagger-ui";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -21,11 +22,12 @@ const app = Fastify({
|
||||
|
||||
await ensureDatabaseReady(app.log);
|
||||
await warmModelCatalog(app.log);
|
||||
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
@@ -80,7 +82,12 @@ app.setErrorHandler((err, req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
stopModelCatalogRefreshLoop();
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
await registerAgentRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
||||
|
||||
+158
-592
@@ -4,15 +4,15 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { convert as htmlToText } from "html-to-text";
|
||||
import type OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
import { searchBrave } from "../search/brave.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
const DEFAULT_WEB_RESULTS = 5;
|
||||
const MAX_WEB_RESULTS = 10;
|
||||
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
||||
@@ -25,7 +25,7 @@ const MAX_SHELL_COMMAND_CHARACTERS = 20_000;
|
||||
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
|
||||
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
|
||||
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
|
||||
const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||
export const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -188,16 +188,40 @@ const CHAT_TOOLS: any[] = [
|
||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
||||
];
|
||||
|
||||
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
function getToolName(tool: any) {
|
||||
return typeof tool?.function?.name === "string" ? tool.function.name : null;
|
||||
}
|
||||
|
||||
export function getAvailableChatTools() {
|
||||
return CHAT_TOOLS.map((tool) => {
|
||||
const name = getToolName(tool);
|
||||
if (!name) return null;
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
name,
|
||||
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
|
||||
};
|
||||
});
|
||||
}).filter((tool): tool is { name: string; description: string } => tool !== null);
|
||||
}
|
||||
|
||||
export function normalizeEnabledChatTools(value: unknown) {
|
||||
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
|
||||
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
|
||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||
available.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||
}
|
||||
|
||||
export function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return CHAT_TOOLS.filter((tool) => {
|
||||
const name = getToolName(tool);
|
||||
return name ? enabled.has(name) : false;
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -212,18 +236,18 @@ export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
: "") +
|
||||
"Do not fabricate tool outputs; reason only from provided tool results.";
|
||||
|
||||
type ToolRunOutcome = {
|
||||
export type ToolRunOutcome = {
|
||||
ok: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ToolAwareUsage = {
|
||||
export type ToolAwareUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
type ToolAwareCompletionResult = {
|
||||
export type ToolAwareCompletionResult = {
|
||||
text: string;
|
||||
usage?: ToolAwareUsage;
|
||||
raw: unknown;
|
||||
@@ -235,10 +259,12 @@ export type ToolAwareStreamingEvent =
|
||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||
| { type: "done"; result: ToolAwareCompletionResult };
|
||||
|
||||
type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
export type ToolAwareCompletionParams = {
|
||||
client: any;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||
@@ -249,15 +275,17 @@ type ToolAwareCompletionParams = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
|
||||
|
||||
export type ToolExecutionEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: ToolExecutionStatus;
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -285,10 +313,13 @@ function toSingleLine(value: string, maxLength = 220) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
|
||||
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
||||
if (name === "web_search") {
|
||||
const query = typeof args.query === "string" ? args.query.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
||||
}
|
||||
@@ -297,6 +328,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
if (name === "fetch_url") {
|
||||
const url = typeof args.url === "string" ? args.url.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
||||
}
|
||||
@@ -305,6 +339,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
if (name === "codex_exec") {
|
||||
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
||||
}
|
||||
@@ -313,6 +350,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
if (name === "shell_exec") {
|
||||
const command = typeof args.command === "string" ? args.command.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
||||
}
|
||||
@@ -321,6 +361,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
: `Devbox shell command failed.${errSuffix}`;
|
||||
}
|
||||
|
||||
if (status === "initiated") {
|
||||
return `Running tool '${name}'.`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return `Ran tool '${name}'.`;
|
||||
}
|
||||
@@ -379,20 +422,22 @@ function extractHtmlTitle(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
}
|
||||
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
export function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return (
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
|
||||
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
|
||||
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
|
||||
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
|
||||
(enabled.has("codex_exec")
|
||||
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
|
||||
: "") +
|
||||
(enabled.has("shell_exec")
|
||||
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
|
||||
: "") +
|
||||
"Do not fabricate tool outputs; reason only from provided tool results."
|
||||
);
|
||||
}
|
||||
|
||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
@@ -463,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> {
|
||||
const args = WebSearchArgsSchema.parse(input);
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
||||
return runSearxngWebSearchTool(args);
|
||||
}
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
|
||||
return runBraveWebSearchTool(args);
|
||||
}
|
||||
return runExaWebSearchTool(args);
|
||||
}
|
||||
|
||||
@@ -492,10 +559,7 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
|
||||
response = await fetch(parsed.toString(), {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
||||
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
|
||||
},
|
||||
headers: buildBrowserLikeNavigationHeaders(),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -766,7 +830,7 @@ async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome>
|
||||
return { ok: false, error: `Unknown tool: ${name}` };
|
||||
}
|
||||
|
||||
function parseToolArgs(raw: unknown) {
|
||||
export function parseToolArgs(raw: unknown) {
|
||||
if (typeof raw !== "string") return {};
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
@@ -795,7 +859,7 @@ function buildEventArgs(name: string, args: Record<string, unknown>) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function looksLikeDanglingToolIntent(text: string) {
|
||||
export function looksLikeDanglingToolIntent(text: string) {
|
||||
const normalized = text
|
||||
.toLowerCase()
|
||||
.replace(/[`*_>#-]/g, " ")
|
||||
@@ -811,7 +875,7 @@ function looksLikeDanglingToolIntent(text: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
export function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "assistant", content: text });
|
||||
conversation.push({
|
||||
role: "system",
|
||||
@@ -820,7 +884,7 @@ function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.prompt_tokens ?? 0;
|
||||
acc.outputTokens += usage.completion_tokens ?? 0;
|
||||
@@ -828,79 +892,19 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeResponsesUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.input_tokens ?? 0;
|
||||
acc.outputTokens += usage.output_tokens ?? 0;
|
||||
acc.totalTokens += usage.total_tokens ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getResponseOutputItems(response: any) {
|
||||
return Array.isArray(response?.output) ? response.output : [];
|
||||
}
|
||||
|
||||
function extractResponsesText(response: any, fallback = "") {
|
||||
if (typeof response?.output_text === "string") return response.output_text;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of getResponseOutputItems(response)) {
|
||||
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const content of item.content) {
|
||||
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||
parts.push(content.text);
|
||||
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||
parts.push(content.refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("") || fallback;
|
||||
}
|
||||
|
||||
function extractChatCompletionContent(message: any) {
|
||||
if (typeof message?.content === "string") return message.content;
|
||||
if (!Array.isArray(message?.content)) return "";
|
||||
|
||||
return message.content
|
||||
.map((part: any) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (typeof part?.text === "string") return part.text;
|
||||
if (typeof part?.content === "string") return part.content;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
||||
export function getUnstreamedText(finalText: string, streamedText: string) {
|
||||
if (!finalText) return "";
|
||||
if (!streamedText) return finalText;
|
||||
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
||||
}
|
||||
|
||||
function getResponseFailureMessage(response: any) {
|
||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||
}
|
||||
|
||||
function normalizeResponsesToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||
return outputItems
|
||||
.filter((item) => item?.type === "function_call")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments ?? "{}",
|
||||
}));
|
||||
}
|
||||
|
||||
type NormalizedToolCall = {
|
||||
export type NormalizedToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||
export function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||
return toolCalls.map((call: any, index: number) => ({
|
||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||
name: call?.function?.name ?? "unknown_tool",
|
||||
@@ -908,17 +912,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
||||
}));
|
||||
}
|
||||
|
||||
async function executeToolCallAndBuildEvent(
|
||||
call: NormalizedToolCall,
|
||||
params: ToolAwareCompletionParams
|
||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||
export type PreparedToolCallExecution = {
|
||||
startedAtMs: number;
|
||||
startedAt: string;
|
||||
parsedArgs: Record<string, unknown>;
|
||||
eventArgs: Record<string, unknown>;
|
||||
parseError?: unknown;
|
||||
};
|
||||
|
||||
export function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||
const startedAtMs = Date.now();
|
||||
const startedAt = new Date(startedAtMs).toISOString();
|
||||
let toolResult: ToolRunOutcome;
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
|
||||
let parseError: unknown;
|
||||
try {
|
||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||
toolResult = await executeTool(call.name, parsedArgs);
|
||||
} catch (err) {
|
||||
parseError = err;
|
||||
}
|
||||
|
||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||
return {
|
||||
event: {
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
status: "initiated",
|
||||
summary: buildToolSummary(call.name, eventArgs, "initiated"),
|
||||
args: eventArgs,
|
||||
startedAt,
|
||||
},
|
||||
execution: {
|
||||
startedAtMs,
|
||||
startedAt,
|
||||
parsedArgs,
|
||||
eventArgs,
|
||||
parseError,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeToolCallAndBuildEvent(
|
||||
call: NormalizedToolCall,
|
||||
execution: PreparedToolCallExecution,
|
||||
params: ToolAwareCompletionParams
|
||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||
let toolResult: ToolRunOutcome;
|
||||
try {
|
||||
if (execution.parseError) throw execution.parseError;
|
||||
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||
} catch (err: any) {
|
||||
toolResult = {
|
||||
ok: false,
|
||||
@@ -935,16 +977,15 @@ async function executeToolCallAndBuildEvent(
|
||||
: undefined;
|
||||
|
||||
const completedAtMs = Date.now();
|
||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||
const event: ToolExecutionEvent = {
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
status,
|
||||
summary: buildToolSummary(call.name, eventArgs, status, error),
|
||||
args: eventArgs,
|
||||
startedAt,
|
||||
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||
args: execution.eventArgs,
|
||||
startedAt: execution.startedAt,
|
||||
completedAt: new Date(completedAtMs).toISOString(),
|
||||
durationMs: completedAtMs - startedAtMs,
|
||||
durationMs: completedAtMs - execution.startedAtMs,
|
||||
error,
|
||||
resultPreview: buildResultPreview(toolResult),
|
||||
};
|
||||
@@ -955,478 +996,3 @@ async function executeToolCallAndBuildEvent(
|
||||
|
||||
return { event, toolResult };
|
||||
}
|
||||
|
||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
store: true,
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeResponsesUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const failureMessage = getResponseFailureMessage(response);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getResponseOutputItems(response);
|
||||
const normalizedToolCalls = normalizeResponsesToolCalls(outputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractResponsesText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
if (!message) {
|
||||
return {
|
||||
text: "",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
if (!toolCalls.length) {
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (typeof message.content === "string" && message.content.length) {
|
||||
assistantToolCallMessage.content = message.content;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
|
||||
return {
|
||||
text: extractChatCompletionContent(message),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response: completion, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runToolAwareOpenAIChatStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
store: true,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let canStreamRoundText = false;
|
||||
let completedResponse: any | null = null;
|
||||
const completedOutputItems: any[] = [];
|
||||
|
||||
for await (const event of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(event);
|
||||
|
||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
roundText += event.delta;
|
||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||
streamedRoundText += event.delta;
|
||||
yield { type: "delta", text: event.delta };
|
||||
}
|
||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||
canStreamRoundText = true;
|
||||
}
|
||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
}
|
||||
} else if (event?.type === "response.completed") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "error") {
|
||||
throw new Error(event.message ?? "OpenAI Responses stream failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = getResponseFailureMessage(completedResponse);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getResponseOutputItems(completedResponse);
|
||||
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractResponsesText(completedResponse, roundText);
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(text)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runToolAwareChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const choice = chunk?.choices?.[0];
|
||||
const deltaText = choice?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
roundText += deltaText;
|
||||
if (!roundHasToolCalls) {
|
||||
streamedRoundText += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||
if (deltaToolCalls.length) {
|
||||
roundHasToolCalls = true;
|
||||
}
|
||||
for (const toolCall of deltaToolCalls) {
|
||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||
entry.id = toolCall.id;
|
||||
}
|
||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||
entry.name = toolCall.function.name;
|
||||
}
|
||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||
entry.arguments += toolCall.function.arguments;
|
||||
}
|
||||
roundToolCalls.set(idx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([_, call], index) => ({
|
||||
id: call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments || "{}",
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(roundText)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (roundText) {
|
||||
assistantToolCallMessage.content = roundText;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runPlainChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
text += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
||||
|
||||
const DEFAULT_USER_LOCATION = "San Francisco, CA";
|
||||
|
||||
function currentDateString(now = new Date()) {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveUserLocation(userLocation?: string) {
|
||||
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
|
||||
}
|
||||
|
||||
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
|
||||
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return value.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function getImageAttachments(message: ChatMessage) {
|
||||
export function getImageAttachments(message: ChatMessage) {
|
||||
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
|
||||
}
|
||||
|
||||
function getTextAttachments(message: ChatMessage) {
|
||||
export function getTextAttachments(message: ChatMessage) {
|
||||
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
|
||||
}
|
||||
|
||||
function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||
export function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||
if (!attachments.length) return null;
|
||||
const label = attachments.length === 1 ? "Attached image" : "Attached images";
|
||||
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
|
||||
}
|
||||
|
||||
function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
export function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
|
||||
return [
|
||||
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
|
||||
@@ -28,83 +42,7 @@ function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function toOpenAIContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function toOpenAIResponsesContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
image_url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "input_text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "input_text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
export function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
|
||||
@@ -121,111 +59,19 @@ function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
};
|
||||
}
|
||||
|
||||
function toAnthropicContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
blocks.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
blocks.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||
return blocks[0].text;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function buildOpenAIConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: toOpenAIContent(message),
|
||||
};
|
||||
|
||||
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||
out.name = message.name;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: toOpenAIResponsesContent(message),
|
||||
role: "system",
|
||||
content: buildSystemPromptAugmentation(userLocation),
|
||||
};
|
||||
}
|
||||
|
||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
||||
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||
export function buildTopLevelSystemPrompt(messages: ChatMessage[], userLocation?: string, toolSystemPrompt?: string) {
|
||||
return [toolSystemPrompt, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildAnthropicConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for Anthropic.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "assistant" : "user",
|
||||
content: toAnthropicContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
fetchProviderCatalogModels,
|
||||
getProviderCatalogFallbackModels,
|
||||
listModelCatalogProviders,
|
||||
} from "./provider-adapters.js";
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
export type ProviderModelSnapshot = {
|
||||
@@ -11,31 +14,12 @@ export type ProviderModelSnapshot = {
|
||||
|
||||
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||
|
||||
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelCatalog: ModelCatalogSnapshot = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
anthropic: { models: [], loadedAt: null, error: null },
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
const modelCatalog: ModelCatalogSnapshot = {};
|
||||
|
||||
function getCatalogProviders(): Provider[] {
|
||||
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||
}
|
||||
|
||||
function uniqSorted(models: string[]) {
|
||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function isLikelyOpenAIResponsesModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
let catalogRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
@@ -53,31 +37,9 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviderModels(provider: Provider) {
|
||||
if (provider === "openai") {
|
||||
const page = await openaiClient().models.list();
|
||||
return uniqSorted(page.data.map((model) => model.id).filter(isLikelyOpenAIResponsesModel));
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const page = await anthropicClient().models.list({ limit: 200 });
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "xai") {
|
||||
const page = await xaiClient().models.list();
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
const page = await hermesAgentClient().models.list();
|
||||
const models = page.data.map((model) => model.id);
|
||||
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||
return uniqSorted(models);
|
||||
}
|
||||
|
||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||
try {
|
||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||
const models = await withTimeout(fetchProviderCatalogModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||
modelCatalog[provider] = {
|
||||
models,
|
||||
loadedAt: new Date().toISOString(),
|
||||
@@ -86,22 +48,47 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? String(err);
|
||||
const previous = modelCatalog[provider];
|
||||
const fallbackModels = getProviderCatalogFallbackModels(provider);
|
||||
modelCatalog[provider] = {
|
||||
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
|
||||
loadedAt: new Date().toISOString(),
|
||||
models: previous?.models.length ? previous.models : fallbackModels,
|
||||
loadedAt: previous?.loadedAt ?? null,
|
||||
error: message,
|
||||
};
|
||||
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
||||
if (catalogRefreshPromise) return catalogRefreshPromise;
|
||||
|
||||
catalogRefreshPromise = Promise.all(listModelCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
catalogRefreshPromise = null;
|
||||
});
|
||||
|
||||
return catalogRefreshPromise;
|
||||
}
|
||||
|
||||
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
||||
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
|
||||
await refreshModelCatalog(logger);
|
||||
}
|
||||
|
||||
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
||||
const timer = setInterval(() => {
|
||||
void refreshModelCatalog(logger);
|
||||
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
|
||||
timer.unref?.();
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||
const snapshot: ModelCatalogSnapshot = {};
|
||||
for (const provider of getCatalogProviders()) {
|
||||
for (const provider of listModelCatalogProviders()) {
|
||||
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||
snapshot[provider] = {
|
||||
models: [...entry.models],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { buildToolLogMessageData } from "./chat-tools.js";
|
||||
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||
|
||||
@@ -47,13 +46,12 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
let usage: MultiplexResponse["usage"] | undefined;
|
||||
let raw: unknown;
|
||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||
|
||||
if (req.provider === "openai") {
|
||||
const client = openaiClient();
|
||||
const r = await runToolAwareOpenAIChat({
|
||||
client,
|
||||
const adapter = getProviderChatAdapter(req.provider);
|
||||
const r = await adapter.complete({
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools: req.enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -66,72 +64,6 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "xai") {
|
||||
const client = xaiClient();
|
||||
const r = await runToolAwareChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
},
|
||||
});
|
||||
raw = r.raw;
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "hermes-agent") {
|
||||
const client = hermesAgentClient();
|
||||
const r = await runPlainChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
},
|
||||
});
|
||||
raw = r.raw;
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const r = await client.messages.create({
|
||||
model: req.model,
|
||||
system,
|
||||
max_tokens: req.maxTokens ?? 1024,
|
||||
temperature: req.temperature,
|
||||
messages: msgs as any,
|
||||
});
|
||||
raw = r;
|
||||
outText = r.content
|
||||
.map((c: any) => (c.type === "text" ? c.text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
// Anthropic usage (SDK typing varies by version)
|
||||
const ru: any = (r as any).usage;
|
||||
if (ru) {
|
||||
usage = {
|
||||
inputTokens: ru.input_tokens,
|
||||
outputTokens: ru.output_tokens,
|
||||
totalTokens: (ru.input_tokens ?? 0) + (ru.output_tokens ?? 0),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown provider: ${req.provider}`);
|
||||
}
|
||||
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
appendDanglingToolIntentCorrection,
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
mergeUsage,
|
||||
normalizeModelToolCalls,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
buildTextAttachmentPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: toContentParts(message),
|
||||
};
|
||||
|
||||
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||
out.name = message.name;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildConversationMessage(message));
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildConversationMessage(message))];
|
||||
}
|
||||
|
||||
function extractContent(message: any) {
|
||||
if (typeof message?.content === "string") return message.content;
|
||||
if (!Array.isArray(message?.content)) return "";
|
||||
|
||||
return message.content
|
||||
.map((part: any) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (typeof part?.text === "string") return part.text;
|
||||
if (typeof part?.content === "string") return part.content;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function completeWithChatCompletionsApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
|
||||
return {
|
||||
text: extractContent(message),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response: completion, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
if (!message) {
|
||||
return {
|
||||
text: "",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
if (!toolCalls.length) {
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (typeof message.content === "string" && message.content.length) {
|
||||
assistantToolCallMessage.content = message.content;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithChatCompletionsApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
text += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const choice = chunk?.choices?.[0];
|
||||
const deltaText = choice?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
roundText += deltaText;
|
||||
if (!roundHasToolCalls) {
|
||||
streamedRoundText += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||
if (deltaToolCalls.length) {
|
||||
roundHasToolCalls = true;
|
||||
}
|
||||
for (const toolCall of deltaToolCalls) {
|
||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||
entry.id = toolCall.id;
|
||||
}
|
||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||
entry.name = toolCall.function.name;
|
||||
}
|
||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||
entry.arguments += toolCall.function.arguments;
|
||||
}
|
||||
roundToolCalls.set(idx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([_, call], index) => ({
|
||||
id: call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments || "{}",
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (roundText) {
|
||||
assistantToolCallMessage.content = roundText;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import {
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
parseToolArgs,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
type ToolRunOutcome,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildTextAttachmentPrompt,
|
||||
buildTopLevelSystemPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
parseImageDataUrl,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
|
||||
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
|
||||
|
||||
function readMaxTokens(value: unknown) {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
function getModelInfoMaxTokens(modelInfo: any) {
|
||||
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
|
||||
}
|
||||
|
||||
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
|
||||
if (params.maxTokens) return params.maxTokens;
|
||||
|
||||
const cached = modelMaxTokensCache.get(params.model);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
|
||||
|
||||
try {
|
||||
const retrieve = params.client?.models?.retrieve;
|
||||
if (typeof retrieve === "function") {
|
||||
const modelInfo = await retrieve.call(params.client.models, params.model);
|
||||
const maxTokens = getModelInfoMaxTokens(modelInfo);
|
||||
if (maxTokens) {
|
||||
modelMaxTokensCache.set(params.model, {
|
||||
maxTokens,
|
||||
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
|
||||
});
|
||||
return maxTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
|
||||
}
|
||||
|
||||
return DEFAULT_ANTHROPIC_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function toTools(tools: any[]) {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
if (tool?.type !== "function") return null;
|
||||
return {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toContentBlocks(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
blocks.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
blocks.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||
return blocks[0].text;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function buildConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for top-level-system protocols.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "assistant" : "user",
|
||||
content: toContentBlocks(message),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseMessages(params: ToolAwareCompletionParams) {
|
||||
return params.messages.filter((message) => message.role !== "system").map((message) => buildConversationMessage(message));
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown) {
|
||||
if (typeof input === "string") return input;
|
||||
try {
|
||||
return JSON.stringify(input ?? {});
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolCalls(content: any[], round: number): NormalizedToolCall[] {
|
||||
return content
|
||||
.filter((item) => item?.type === "tool_use")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||
name: call?.name ?? "unknown_tool",
|
||||
arguments: stringifyToolInput(call?.input),
|
||||
}));
|
||||
}
|
||||
|
||||
function extractText(response: any) {
|
||||
if (!Array.isArray(response?.content)) return "";
|
||||
return response.content
|
||||
.map((content: any) => (content?.type === "text" && typeof content.text === "string" ? content.text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildToolResultBlock(call: NormalizedToolCall, toolResult: ToolRunOutcome) {
|
||||
return {
|
||||
type: "tool_result",
|
||||
tool_use_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
is_error: !toolResult.ok,
|
||||
};
|
||||
}
|
||||
|
||||
function appendCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "assistant", content: text });
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: INTERNAL_CORRECTION,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
const inputTokens = usage.input_tokens ?? 0;
|
||||
const outputTokens = usage.output_tokens ?? 0;
|
||||
acc.inputTokens += inputTokens;
|
||||
acc.outputTokens += outputTokens;
|
||||
acc.totalTokens += inputTokens + outputTokens;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, response?.usage);
|
||||
|
||||
return {
|
||||
text: extractText(response),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response, api: "messages" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const conversation: any[] = buildBaseMessages(params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
tool_choice: { type: "auto" },
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
const normalizedToolCalls = normalizeToolCalls(content, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
});
|
||||
|
||||
const toolResultBlocks: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let roundInputTokens = 0;
|
||||
let roundOutputTokens = 0;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(ev);
|
||||
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||
const delta = ev.delta.text ?? "";
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
if (ev?.type === "message_delta" && ev.usage) {
|
||||
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawUsage) {
|
||||
usageAcc.inputTokens += roundInputTokens;
|
||||
usageAcc.outputTokens += roundOutputTokens;
|
||||
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "messages" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation: any[] = buildBaseMessages(params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
const contentByIndex = new Map<number, any>();
|
||||
const toolArgumentByIndex = new Map<number, string>();
|
||||
let roundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let roundInputTokens = 0;
|
||||
let roundOutputTokens = 0;
|
||||
let sawRoundUsage = false;
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(ev);
|
||||
|
||||
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
|
||||
const block = ev.content_block ?? {};
|
||||
if (block.type === "tool_use") {
|
||||
roundHasToolCalls = true;
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
toolArgumentByIndex.set(ev.index, "");
|
||||
} else if (block.type === "text") {
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "text",
|
||||
text: typeof block.text === "string" ? block.text : "",
|
||||
});
|
||||
} else if (block.type) {
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
|
||||
if (ev.delta?.type === "text_delta") {
|
||||
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
|
||||
if (delta) {
|
||||
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
|
||||
if (block.type === "text") {
|
||||
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
roundText += delta;
|
||||
}
|
||||
} else if (ev.delta?.type === "input_json_delta") {
|
||||
roundHasToolCalls = true;
|
||||
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
|
||||
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
|
||||
const block = contentByIndex.get(ev.index);
|
||||
if (block?.type === "tool_use") {
|
||||
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
|
||||
try {
|
||||
block.input = parseToolArgs(rawArguments);
|
||||
} catch {
|
||||
block.input = {};
|
||||
}
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "message_delta" && ev.usage) {
|
||||
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawRoundUsage) {
|
||||
usageAcc.inputTokens += roundInputTokens;
|
||||
usageAcc.outputTokens += roundOutputTokens;
|
||||
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
|
||||
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
||||
const assistantContent = indexedContent.map(([, block]) => block);
|
||||
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
|
||||
.filter(([, block]) => block?.type === "tool_use")
|
||||
.map(([index, block], callIndex) => ({
|
||||
id: block.id ?? `tool_call_${round}_${callIndex}`,
|
||||
name: block.name ?? "unknown_tool",
|
||||
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
if (roundText) {
|
||||
yield { type: "delta", text: roundText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
});
|
||||
|
||||
const toolResultBlocks: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
appendDanglingToolIntentCorrection,
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
buildTextAttachmentPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
function toResponsesTools(tools: any[]) {
|
||||
return tools.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
image_url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "input_text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "input_text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildInputMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: toContentParts(message),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildInputMessage(message));
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.input_tokens ?? 0;
|
||||
acc.outputTokens += usage.output_tokens ?? 0;
|
||||
acc.totalTokens += usage.total_tokens ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getOutputItems(response: any) {
|
||||
return Array.isArray(response?.output) ? response.output : [];
|
||||
}
|
||||
|
||||
function extractText(response: any, fallback = "") {
|
||||
if (typeof response?.output_text === "string") return response.output_text;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of getOutputItems(response)) {
|
||||
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const content of item.content) {
|
||||
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||
parts.push(content.text);
|
||||
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||
parts.push(content.refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("") || fallback;
|
||||
}
|
||||
|
||||
function getFailureMessage(response: any) {
|
||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||
}
|
||||
|
||||
function normalizeToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||
return outputItems
|
||||
.filter((item) => item?.type === "function_call")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments ?? "{}",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function completeWithResponsesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
store: true,
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const failureMessage = getFailureMessage(response);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getOutputItems(response);
|
||||
const normalizedToolCalls = normalizeToolCalls(outputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithResponsesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
store: true,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let canStreamRoundText = false;
|
||||
let completedResponse: any | null = null;
|
||||
const completedOutputItems: any[] = [];
|
||||
|
||||
for await (const event of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(event);
|
||||
|
||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
roundText += event.delta;
|
||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||
streamedRoundText += event.delta;
|
||||
yield { type: "delta", text: event.delta };
|
||||
}
|
||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||
canStreamRoundText = true;
|
||||
}
|
||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
}
|
||||
} else if (event?.type === "response.completed") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "error") {
|
||||
throw new Error(event.message ?? "Responses stream failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = getFailureMessage(completedResponse);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getOutputItems(completedResponse);
|
||||
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||
const normalizedToolCalls = normalizeToolCalls(responseOutputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(completedResponse, roundText);
|
||||
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
normalizeEnabledChatTools,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||
import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import type { ChatMessage, Provider } from "./types.js";
|
||||
|
||||
type ProviderAdapterParams = {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
logContext?: ToolAwareCompletionParams["logContext"];
|
||||
};
|
||||
|
||||
export type ProviderChatAdapter = {
|
||||
provider: Provider;
|
||||
complete(params: ProviderAdapterParams): Promise<ToolAwareCompletionResult>;
|
||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||
|
||||
type ChatProtocol = {
|
||||
id: ChatProtocolId;
|
||||
complete(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult>;
|
||||
stream(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ModelCatalogSpec = {
|
||||
enabled?: () => boolean;
|
||||
fetchModels(client: any): Promise<string[]>;
|
||||
fallbackModels?: () => string[];
|
||||
sortModels?: (models: string[]) => string[];
|
||||
};
|
||||
|
||||
type ProviderBackendSpec = {
|
||||
createClient: () => any;
|
||||
plainProtocol: ChatProtocol;
|
||||
toolProtocol?: ChatProtocol;
|
||||
managedTools?: boolean;
|
||||
modelCatalog?: ModelCatalogSpec;
|
||||
};
|
||||
|
||||
const chatCompletionsProtocol: ChatProtocol = {
|
||||
id: "chat-completions",
|
||||
complete: completeWithChatCompletionsApi,
|
||||
stream: streamWithChatCompletionsApi,
|
||||
};
|
||||
|
||||
const messagesProtocol: ChatProtocol = {
|
||||
id: "messages",
|
||||
complete: completeWithMessagesApi,
|
||||
stream: streamWithMessagesApi,
|
||||
};
|
||||
|
||||
const geminiProtocol: ChatProtocol = {
|
||||
id: "gemini",
|
||||
complete: completeWithGeminiApi,
|
||||
stream: streamWithGeminiApi,
|
||||
};
|
||||
|
||||
const responsesProtocol: ChatProtocol = {
|
||||
id: "responses",
|
||||
complete: completeWithResponsesApi,
|
||||
stream: streamWithResponsesApi,
|
||||
};
|
||||
|
||||
function uniqSorted(values: string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function modelIdsFromListResponse(page: any) {
|
||||
return Array.isArray(page?.data)
|
||||
? page.data.map((model: any) => model?.id).filter((id: unknown): id is string => typeof id === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function stripModelResourcePrefix(model: string) {
|
||||
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||
}
|
||||
|
||||
function isLikelyResponsesApiModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function isLikelyGeminiChatModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (!id.startsWith("gemini-")) return false;
|
||||
if (id.includes("embedding") || id.includes("embed")) return false;
|
||||
if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false;
|
||||
if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false;
|
||||
if (id.includes("computer-use") || id.includes("robotics")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function preferGeminiModels(models: string[]) {
|
||||
const preferred = [
|
||||
"gemini-3.5-flash",
|
||||
"gemini-flash-latest",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-pro-latest",
|
||||
];
|
||||
const modelSet = new Set(models);
|
||||
return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))];
|
||||
}
|
||||
|
||||
async function fetchJson(url: URL): Promise<any> {
|
||||
const response = await fetch(url);
|
||||
const body: any = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||
return {
|
||||
client,
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
enabledTools,
|
||||
userLocation: params.userLocation,
|
||||
temperature: params.temperature,
|
||||
maxTokens: params.maxTokens,
|
||||
logContext: params.logContext,
|
||||
};
|
||||
}
|
||||
|
||||
function selectChatProtocol(spec: ProviderBackendSpec, params: Pick<ProviderAdapterParams, "enabledTools">) {
|
||||
const enabledTools = normalizeEnabledChatTools(params.enabledTools);
|
||||
const useManagedTools = spec.managedTools === true && spec.toolProtocol && enabledTools.length > 0;
|
||||
return {
|
||||
protocol: useManagedTools ? spec.toolProtocol! : spec.plainProtocol,
|
||||
enabledTools: useManagedTools ? enabledTools : [],
|
||||
managedTools: Boolean(useManagedTools),
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderChatAdapter(provider: Provider, spec: ProviderBackendSpec): ProviderChatAdapter {
|
||||
return {
|
||||
provider,
|
||||
complete(params) {
|
||||
const selected = selectChatProtocol(spec, params);
|
||||
return selected.protocol.complete(withClient(params, spec.createClient(), selected.enabledTools));
|
||||
},
|
||||
stream(params) {
|
||||
const selected = selectChatProtocol(spec, params);
|
||||
return selected.protocol.stream(withClient(params, spec.createClient(), selected.enabledTools));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||
openai: {
|
||||
createClient: openaiClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
toolProtocol: responsesProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
return modelIdsFromListResponse(page).filter(isLikelyResponsesApiModel);
|
||||
},
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
createClient: anthropicClient,
|
||||
plainProtocol: messagesProtocol,
|
||||
toolProtocol: messagesProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list({ limit: 200 });
|
||||
return modelIdsFromListResponse(page);
|
||||
},
|
||||
},
|
||||
},
|
||||
xai: {
|
||||
createClient: xaiClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
toolProtocol: chatCompletionsProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
return modelIdsFromListResponse(page);
|
||||
},
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
createClient: geminiClient,
|
||||
plainProtocol: geminiProtocol,
|
||||
toolProtocol: geminiProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
||||
url.searchParams.set("key", client.apiKey);
|
||||
url.searchParams.set("pageSize", "1000");
|
||||
const page = await fetchJson(url);
|
||||
return Array.isArray(page?.models)
|
||||
? page.models
|
||||
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
||||
.map((model: any) => model?.name)
|
||||
.filter((id: unknown): id is string => typeof id === "string")
|
||||
.map(stripModelResourcePrefix)
|
||||
.filter(isLikelyGeminiChatModel)
|
||||
: [];
|
||||
},
|
||||
sortModels: preferGeminiModels,
|
||||
},
|
||||
},
|
||||
"hermes-agent": {
|
||||
createClient: hermesAgentClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
managedTools: false,
|
||||
modelCatalog: {
|
||||
enabled: isHermesAgentConfigured,
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
const models = modelIdsFromListResponse(page);
|
||||
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||
return models;
|
||||
},
|
||||
fallbackModels() {
|
||||
return env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const providerChatAdapters: Record<Provider, ProviderChatAdapter> = Object.fromEntries(
|
||||
Object.entries(backendSpecs).map(([provider, spec]) => [provider, createProviderChatAdapter(provider as Provider, spec)])
|
||||
) as Record<Provider, ProviderChatAdapter>;
|
||||
|
||||
export function getProviderChatAdapter(provider: Provider) {
|
||||
return providerChatAdapters[provider];
|
||||
}
|
||||
|
||||
export function describeProviderChatBackend(provider: Provider, enabledTools?: string[]) {
|
||||
const selected = selectChatProtocol(backendSpecs[provider], { enabledTools });
|
||||
return {
|
||||
provider,
|
||||
protocol: selected.protocol.id,
|
||||
managedTools: selected.managedTools,
|
||||
enabledTools: selected.enabledTools,
|
||||
};
|
||||
}
|
||||
|
||||
export function listModelCatalogProviders(): Provider[] {
|
||||
return (Object.entries(backendSpecs) as [Provider, ProviderBackendSpec][])
|
||||
.filter(([, spec]) => {
|
||||
const catalog = spec.modelCatalog;
|
||||
return catalog !== undefined && catalog.enabled?.() !== false;
|
||||
})
|
||||
.map(([provider]) => provider);
|
||||
}
|
||||
|
||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||
const spec = backendSpecs[provider].modelCatalog;
|
||||
if (!spec) return [];
|
||||
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
return spec.sortModels ? spec.sortModels(models) : models;
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
|
||||
}
|
||||
@@ -2,15 +2,30 @@ import type { Provider } from "./types.js";
|
||||
|
||||
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||
|
||||
const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
const prismaToApiProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
hermes_agent: "hermes-agent",
|
||||
"hermes-agent": "hermes-agent",
|
||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||
|
||||
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||
return provider === "hermes-agent" ? "hermes_agent" : provider;
|
||||
return apiToPrismaProvider[provider];
|
||||
}
|
||||
|
||||
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||
if (provider === null || provider === undefined) return null;
|
||||
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
|
||||
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
|
||||
return null;
|
||||
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? null;
|
||||
}
|
||||
|
||||
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export function openaiClient() {
|
||||
@@ -13,6 +13,14 @@ export function xaiClient() {
|
||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||
}
|
||||
|
||||
export function geminiClient() {
|
||||
if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set");
|
||||
return {
|
||||
apiKey: env.GEMINI_API_KEY,
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
};
|
||||
}
|
||||
|
||||
export function isHermesAgentConfigured() {
|
||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
||||
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
||||
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
||||
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
||||
|
||||
export type QuickQuestionRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
question: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export function buildQuickQuestionMultiplexRequest({
|
||||
question,
|
||||
...request
|
||||
}: QuickQuestionRequest): MultiplexRequest {
|
||||
return {
|
||||
...request,
|
||||
persist: false,
|
||||
messages: [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
};
|
||||
}
|
||||
+13
-77
@@ -1,14 +1,10 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolExecutionEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
@@ -74,39 +70,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
let raw: unknown = { streamed: true };
|
||||
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const streamEvents =
|
||||
req.provider === "openai"
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: req.provider === "hermes-agent"
|
||||
? runPlainChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: runToolAwareChatCompletionsStream({
|
||||
client,
|
||||
const adapter = getProviderChatAdapter(req.provider);
|
||||
const streamEvents = adapter.stream({
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools: req.enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -115,6 +84,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
for await (const ev of streamEvents) {
|
||||
if (ev.type === "delta") {
|
||||
text += ev.text;
|
||||
@@ -123,7 +93,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
}
|
||||
|
||||
if (ev.type === "tool_call") {
|
||||
if (shouldPersist && chatId) {
|
||||
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
@@ -143,52 +113,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
usage = ev.result.usage;
|
||||
text = ev.result.text;
|
||||
}
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: req.model,
|
||||
system,
|
||||
max_tokens: req.maxTokens ?? 1024,
|
||||
temperature: req.temperature,
|
||||
messages: msgs as any,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
// Anthropic streaming events include content_block_delta with text_delta
|
||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||
const delta = ev.delta.text ?? "";
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
// capture usage if present on message_delta
|
||||
if (ev?.type === "message_delta" && ev?.usage) {
|
||||
usage = {
|
||||
inputTokens: ev.usage.input_tokens,
|
||||
outputTokens: ev.usage.output_tokens,
|
||||
totalTokens:
|
||||
(ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
|
||||
};
|
||||
}
|
||||
// some streams end with message_stop
|
||||
}
|
||||
raw = { streamed: true, provider: "anthropic" };
|
||||
} else {
|
||||
throw new Error(`unknown provider: ${req.provider}`);
|
||||
}
|
||||
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
|
||||
if (shouldPersist && chatId && call) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.message.create({
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant" as any,
|
||||
content: text,
|
||||
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||
},
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -33,9 +33,13 @@ export type ChatMessage = {
|
||||
export type MultiplexRequest = {
|
||||
chatId?: string;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
+689
-107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||
}
|
||||
|
||||
export function isFreshSearchCacheHit(
|
||||
candidate: {
|
||||
updatedAt: Date | string;
|
||||
resultCount: number;
|
||||
answerText?: string | null;
|
||||
isActive?: boolean;
|
||||
},
|
||||
now = new Date(),
|
||||
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||
) {
|
||||
if (candidate.isActive) return false;
|
||||
if (!hasReusableSearchPayload(candidate)) return false;
|
||||
|
||||
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||
if (!Number.isFinite(updatedAtMs)) return false;
|
||||
|
||||
return now.getTime() - updatedAtMs <= ttlMs;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const SEARXNG_TIMEOUT_MS = 12_000;
|
||||
@@ -106,10 +107,7 @@ async function fetchSearxng(url: URL, accept: string) {
|
||||
return await fetch(url, {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
||||
Accept: accept,
|
||||
},
|
||||
headers: buildBrowserLikeRequestHeaders(accept),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./db.js";
|
||||
|
||||
export const WORKSPACE_SEARCH_LIMIT = 100;
|
||||
|
||||
// Treat input as literal Unicode words, never FTS operators/column expressions.
|
||||
// Prefix matching supports type-ahead, including short terms such as "AI".
|
||||
export function workspaceSearchExpression(query: string): string | null {
|
||||
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
|
||||
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
|
||||
}
|
||||
|
||||
export type WorkspaceSearchHit = {
|
||||
type: "chat" | "search";
|
||||
id: string;
|
||||
searchSnippet: string | null;
|
||||
};
|
||||
|
||||
export function workspaceSearchSQL(expression: string) {
|
||||
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
|
||||
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
|
||||
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
|
||||
return Prisma.sql`
|
||||
WITH chat_hits AS (
|
||||
SELECT c.id, NULL AS message_rowid
|
||||
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
|
||||
WHERE "ChatSearch" MATCH ${expression}
|
||||
UNION ALL
|
||||
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
|
||||
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
GROUP BY m.chatId
|
||||
), matches AS (
|
||||
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
|
||||
NULL AS search_rowid
|
||||
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
|
||||
GROUP BY c.id
|
||||
UNION ALL
|
||||
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
|
||||
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
), limited AS MATERIALIZED (
|
||||
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
|
||||
), message_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "MessageSearch"
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT message_rowid FROM limited)
|
||||
), search_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "SavedSearchSearch"
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT search_rowid FROM limited)
|
||||
)
|
||||
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
|
||||
FROM limited
|
||||
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
|
||||
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
|
||||
ORDER BY updatedAt DESC, type, id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function searchWorkspace(query: string) {
|
||||
const expression = workspaceSearchExpression(query);
|
||||
if (!expression) return { hits: [], hasMore: false };
|
||||
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
|
||||
// only for the bounded result set, never fetch full transcripts into JS.
|
||||
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||||
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { createConnection } from 'node:net';
|
||||
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';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
});
|
||||
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
|
||||
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
|
||||
]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
|
||||
const privateKey = 'sybil-agent-private-key-for-test';
|
||||
const requests: { method?: string; url?: string; body: any }[] = [];
|
||||
const accepted = new Map<string, number>();
|
||||
let upstreamReply: { status: number; body: string } | undefined;
|
||||
let connections = 0;
|
||||
const subscriptions: any[] = [];
|
||||
const upstream = createServer(async (req, res) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||
requests.push({ method: req.method, url: req.url, body });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
|
||||
if (req.method === 'POST') {
|
||||
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
|
||||
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
|
||||
} else if (req.url?.includes('/messages')) {
|
||||
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
|
||||
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
|
||||
});
|
||||
const sourceSockets = new WebSocketServer({ noServer: true });
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
assert.equal(req.url, '/api/ws');
|
||||
connections++;
|
||||
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
||||
ws.on('message', raw => {
|
||||
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
|
||||
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
|
||||
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
|
||||
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
|
||||
});
|
||||
});
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address() as { port: number };
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const address = app.server.address() as { port: number };
|
||||
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
for (const socket of sourceSockets.clients) socket.terminate();
|
||||
sourceSockets.close();
|
||||
await new Promise<void>(resolve => upstream.close(() => resolve()));
|
||||
await prisma.$disconnect();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function eventually(check: () => boolean) {
|
||||
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
|
||||
assert.fail('Expected condition was not reached');
|
||||
}
|
||||
|
||||
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
|
||||
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
|
||||
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
|
||||
assert.equal(descriptor.statusCode, 200);
|
||||
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
|
||||
assert.ok(!descriptor.body.includes(privateKey));
|
||||
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
|
||||
assert.equal(page.json().messages[0].role, 'assistant');
|
||||
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
|
||||
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
|
||||
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
|
||||
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
|
||||
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
|
||||
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
|
||||
assert.equal(initial.json().cursor, '0');
|
||||
assert.equal(initial.headers['cache-control'], 'no-store');
|
||||
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
|
||||
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
|
||||
await prisma.$disconnect();
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
|
||||
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
|
||||
try {
|
||||
upstreamReply = { status: 200, body: '{truncated' };
|
||||
for (const url of ['/v1/agent', '/v1/agent/messages']) {
|
||||
const response = await app.inject({ url, headers: auth });
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
|
||||
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
|
||||
upstreamReply = { status: 202, body };
|
||||
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(response.statusCode, 502, body);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
} finally { upstreamReply = undefined; }
|
||||
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(retry.statusCode, 202);
|
||||
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
|
||||
});
|
||||
|
||||
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
|
||||
const before = connections;
|
||||
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
let response = '';
|
||||
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
|
||||
});
|
||||
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
|
||||
socket.on('data', chunk => { response += chunk.toString(); });
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => { socket.destroy(); resolve(response); });
|
||||
});
|
||||
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
|
||||
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
|
||||
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
|
||||
assert.equal(connections, before);
|
||||
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
|
||||
const before = connections;
|
||||
const denied = new WebSocket(wsUrl); await once(denied, 'open');
|
||||
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
|
||||
const [code] = await once(denied, 'close');
|
||||
assert.equal(code, 4401); assert.equal(connections, before);
|
||||
const browser = new WebSocket(wsUrl);
|
||||
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'activity'));
|
||||
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
browser.close(); await once(browser, 'close');
|
||||
await eventually(() => sourceSockets.clients.size === 0);
|
||||
});
|
||||
|
||||
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try {
|
||||
const browser = new WebSocket(wsUrl); const frames: any[] = [];
|
||||
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'ready'));
|
||||
browser.close(); await once(browser, 'close');
|
||||
} finally { env.ADMIN_TOKEN = saved; }
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } 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")}`;
|
||||
writeFileSync(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);
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "../src/llm/chat-tools.js";
|
||||
import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
|
||||
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
|
||||
|
||||
async function* streamFrom(events: any[]) {
|
||||
for (const event of events) {
|
||||
@@ -22,7 +20,7 @@ async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
|
||||
return events;
|
||||
}
|
||||
|
||||
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
test("Responses API stream emits text deltas as they arrive", async () => {
|
||||
const outputMessage = {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
@@ -52,7 +50,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareOpenAIChatStream({
|
||||
streamWithResponsesApi({
|
||||
client: client as any,
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
@@ -70,7 +68,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||
});
|
||||
|
||||
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
|
||||
test("Chat Completions API stream emits text deltas as they arrive", async () => {
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
@@ -89,7 +87,7 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareChatCompletionsStream({
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
@@ -124,10 +122,11 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runPlainChatCompletionsStream({
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "hermes-agent",
|
||||
messages: [{ role: "user", content: "Say hi" }],
|
||||
enabledTools: [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -140,3 +139,423 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||
});
|
||||
|
||||
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
|
||||
let requestBody: any = null;
|
||||
let retrievedModel: string | null = null;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async (model: string) => {
|
||||
retrievedModel = model;
|
||||
return { id: model, max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-max-default-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
});
|
||||
|
||||
assert.equal(retrievedModel, "claude-max-default-test");
|
||||
assert.equal(requestBody?.max_tokens, 128000);
|
||||
assert.equal(result.text, "Done");
|
||||
});
|
||||
|
||||
test("Messages API preserves explicit maxTokens", async () => {
|
||||
let requestBody: any = null;
|
||||
let didRetrieveModel = false;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async () => {
|
||||
didRetrieveModel = true;
|
||||
return { max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-explicit-max-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
maxTokens: 4096,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(didRetrieveModel, false);
|
||||
assert.equal(requestBody?.max_tokens, 4096);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("fetch_url sends browser-like navigation headers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "fetch_url",
|
||||
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
choices: [{ message: { content: "Fetched" } }],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
|
||||
assert.deepEqual(fetchCalls[0]?.init?.headers, {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
});
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBodies.push(body);
|
||||
if (requestBodies.length === 1) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "fetch_url",
|
||||
input: { url: "https://example.com/article" },
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Fetched" }],
|
||||
usage: { input_tokens: 5, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Fetch the article" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
|
||||
assert.equal(requestBodies.length, 2);
|
||||
assert.equal(requestBodies[0]?.model, "claude-test");
|
||||
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
|
||||
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
|
||||
assert.equal(fetchTool?.input_schema?.type, "object");
|
||||
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
|
||||
assert.equal(secondMessages.at(-1)?.role, "user");
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, false);
|
||||
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
|
||||
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
assert.equal(result.usage?.inputTokens, 8);
|
||||
assert.equal(result.usage?.outputTokens, 3);
|
||||
assert.equal(result.usage?.totalTokens, 11);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
function: {
|
||||
name: "unknown_tool",
|
||||
arguments: "{\"query\":\"current weather\"}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{ choices: [{ delta: { content: "Done" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
]);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("Messages API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestCount += 1;
|
||||
requestBodies.push(body);
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 3, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "I'll check that." },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "unknown_tool",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||
usage: { output_tokens: 2 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 4, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
assert.equal(requestBodies[0]?.stream, true);
|
||||
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
|
||||
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, true);
|
||||
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildSystemPromptAugmentation, buildTopLevelSystemPrompt } from "../src/llm/message-content.js";
|
||||
|
||||
test("system prompt augmentation includes date and default location", () => {
|
||||
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||
|
||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||
});
|
||||
|
||||
test("system prompt augmentation uses provided user location", () => {
|
||||
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||
|
||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||
});
|
||||
|
||||
test("top-level system prompt includes runtime context with existing system messages", () => {
|
||||
const prompt = buildTopLevelSystemPrompt(
|
||||
[{ role: "system", content: "Use concise answers." }],
|
||||
"Los Angeles, CA"
|
||||
);
|
||||
|
||||
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||
assert.match(prompt, /Use concise answers\./);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { env } from "../src/env.js";
|
||||
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||
|
||||
for (const { name, listedModels, expectedModels } of [
|
||||
{
|
||||
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||
listedModels: [
|
||||
"gpt-4.1-mini",
|
||||
"chat-latest",
|
||||
"o3",
|
||||
"chatgpt-4o-latest",
|
||||
"chat-latest",
|
||||
"text-embedding-3-small",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime",
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-tts",
|
||||
"gpt-image-1",
|
||||
"chatgpt-image-latest",
|
||||
"gpt-4o-search-preview",
|
||||
"computer-use-preview",
|
||||
"sora-2",
|
||||
],
|
||||
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||
listedModels: ["gpt-4.1-mini"],
|
||||
expectedModels: ["gpt-4.1-mini"],
|
||||
},
|
||||
]) {
|
||||
test(name, async (t) => {
|
||||
const originalKey = env.OPENAI_API_KEY;
|
||||
env.OPENAI_API_KEY = "test-openai-key";
|
||||
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.equal(url, "https://api.openai.com/v1/models");
|
||||
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||
});
|
||||
}
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
provider: "openai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
|
||||
provider: "openai",
|
||||
protocol: "responses",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
|
||||
provider: "anthropic",
|
||||
protocol: "messages",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
|
||||
provider: "xai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), {
|
||||
provider: "gemini",
|
||||
protocol: "gemini",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||
provider: "hermes-agent",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||
assert.equal(toPrismaProvider("gemini"), "gemini");
|
||||
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||
initiatedProvider: "hermes-agent",
|
||||
lastUsedProvider: "xai",
|
||||
lastUsedProvider: "gemini",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildQuickQuestionMultiplexRequest,
|
||||
QUICK_QUESTION_SYSTEM_PROMPT,
|
||||
} from "../src/llm/quick-question.js";
|
||||
|
||||
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
||||
const request = buildQuickQuestionMultiplexRequest({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
question: "How do I reset my password?",
|
||||
enabledTools: ["web_search"],
|
||||
userLocation: "Los Angeles, CA",
|
||||
});
|
||||
|
||||
assert.equal(request.persist, false);
|
||||
assert.equal(request.chatId, undefined);
|
||||
assert.deepEqual(request.messages, [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: "How do I reset my password?" },
|
||||
]);
|
||||
assert.deepEqual(request.enabledTools, ["web_search"]);
|
||||
assert.equal(request.userLocation, "Los Angeles, CA");
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||
|
||||
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||
assert.equal(normalizeSearchQuery(" "), null);
|
||||
assert.equal(normalizeSearchQuery(null), null);
|
||||
});
|
||||
|
||||
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||
false
|
||||
);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } 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";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
|
||||
const migration = "20261007000000_add_workspace_full_text_search";
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
|
||||
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
|
||||
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
|
||||
}
|
||||
function migrate() {
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
|
||||
cwd: serverRoot, env: process.env, stdio: "pipe",
|
||||
});
|
||||
}
|
||||
migrate();
|
||||
const { prisma } = await import("../src/db.js");
|
||||
const oldChat = await prisma.chat.create({ data: {
|
||||
title: "Old constellation notes",
|
||||
messages: { create: [
|
||||
{ role: "user", content: "We photographed a nebula before the migration." },
|
||||
{ role: "system", content: "secretmigrationprompt" },
|
||||
] },
|
||||
} });
|
||||
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
|
||||
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
|
||||
migrate();
|
||||
|
||||
const { registerRoutes } = await import("../src/routes.js");
|
||||
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
async function search(query: string) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
await prisma.chat.deleteMany();
|
||||
await prisma.search.deleteMany();
|
||||
});
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration indexes existing titles, messages, queries, and answers", async () => {
|
||||
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
|
||||
assert.equal((await search("constellation")).items.length, 2);
|
||||
assert.equal((await search("secretmigrationprompt")).items.length, 0);
|
||||
});
|
||||
|
||||
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "Travel notes", updatedAt: new Date("2000-01-01"),
|
||||
messages: { create: [
|
||||
{ role: "user", content: "Let's discuss café planning in Zürich" },
|
||||
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
|
||||
{ role: "system", content: "hiddensystemvalue" },
|
||||
{ role: "tool", content: "hiddentoolvalue" },
|
||||
] },
|
||||
} });
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
|
||||
const result = await search("CAFE plan");
|
||||
assert.equal(result.hasMore, false);
|
||||
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
|
||||
assert.match(result.items[0].searchSnippet, /café planning/i);
|
||||
assert.equal(result.items[0].messages, undefined);
|
||||
assert.equal((await search("zuri")).items.length, 1);
|
||||
assert.equal((await search("hiddensystemvalue")).items.length, 0);
|
||||
assert.equal((await search("hiddentoolvalue")).items.length, 0);
|
||||
});
|
||||
|
||||
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
|
||||
}, include: { messages: true } });
|
||||
const messageID = chat.messages[0].id;
|
||||
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
|
||||
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
|
||||
assert.equal((await search("original")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 0);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
|
||||
throw new Error("rollback");
|
||||
}));
|
||||
assert.equal((await search("rolledbackvalue")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
|
||||
assert.equal(fork.statusCode, 200, fork.body);
|
||||
assert.equal((await search("replacementbody")).items.length, 2);
|
||||
await prisma.message.delete({ where: { id: messageID } });
|
||||
const onlyFork = (await search("replacementbody")).items;
|
||||
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
|
||||
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
|
||||
assert.equal(starResponse.statusCode, 200, starResponse.body);
|
||||
const starredFork = (await search("replacementbody")).items[0];
|
||||
assert.equal(starredFork.starred, true);
|
||||
assert.ok(starredFork.starredAt);
|
||||
assert.equal(starredFork.parentChat, undefined);
|
||||
await prisma.chat.delete({ where: { id: chat.id } });
|
||||
assert.equal((await search("replacement")).items.length, 0);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("saved searches match query and answer, and update/delete their index", async () => {
|
||||
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
|
||||
assert.equal((await search("ai des")).items[0].type, "search");
|
||||
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
|
||||
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
|
||||
assert.equal((await search("nebula")).items.length, 0);
|
||||
assert.equal((await search("design")).items.length, 0);
|
||||
assert.equal((await search("Bookmarks")).items.length, 0);
|
||||
assert.equal((await search("newanswer")).items.length, 1);
|
||||
await prisma.search.delete({ where: { id: saved.id } });
|
||||
assert.equal((await search("new")).items.length, 0);
|
||||
});
|
||||
|
||||
test("literal queries, validation, blank queries, and bounded snippets", async () => {
|
||||
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
|
||||
assert.equal((await search('"AI" OR safety*')).items.length, 1);
|
||||
assert.equal((await search("AI OR other")).items.length, 0);
|
||||
assert.equal((await search("AI")).items.length, 1);
|
||||
assert.equal((await search("*** : () --")).items.length, 0);
|
||||
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
|
||||
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.deepEqual(await search(" "), recent);
|
||||
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
|
||||
assert.equal(response.statusCode, 400, response.body);
|
||||
}
|
||||
});
|
||||
|
||||
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
|
||||
const date = new Date("2026-01-01");
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
|
||||
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
|
||||
const result = await search("common");
|
||||
assert.equal(result.items.length, 100);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.items[0].id, "saved");
|
||||
assert.equal(result.items[1].id, "chat-000");
|
||||
assert.equal(result.items.at(-1).id, "chat-098");
|
||||
});
|
||||
|
||||
test("query plan uses all three FTS indexes", async () => {
|
||||
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
|
||||
}
|
||||
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
|
||||
});
|
||||
|
||||
test("vacuum preserves external-content index row identities", async () => {
|
||||
const removed = await prisma.chat.create({ data: { title: "removed" } });
|
||||
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
|
||||
await prisma.chat.delete({ where: { id: removed.id } });
|
||||
await prisma.$executeRaw`VACUUM`;
|
||||
assert.equal((await search("kepttitle")).items[0].id, kept.id);
|
||||
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
|
||||
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user