Compare commits
52 Commits
ios-pull-t
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c425f664 | |||
| 297b053a91 | |||
| 7436544a69 | |||
| 95796646b1 | |||
| d7214c88ad | |||
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
| 600bc3befc | |||
| 5b7ed25522 | |||
| 39014eee18 | |||
| a6c2ec664b | |||
| cb8ea935fa | |||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 | |||
| 195e157e1a | |||
| c5dbd12587 | |||
| be072fd46d | |||
| f514c42de6 | |||
| 70a60edf1c | |||
| 91ef28bf29 | |||
| bb713f8806 | |||
| e6cf344527 | |||
| 4bc0773d35 | |||
| d1140d21d4 | |||
| 0c0226e37e | |||
| 0b94d5b3fa | |||
| aff2531bf3 | |||
| ee8a93a8c4 | |||
| 53a3b722ec | |||
| ae783020ef | |||
| 39acefb55a | |||
| e6fe63280a | |||
| 2403dd99ae | |||
| 89bd418566 | |||
| e02168854c | |||
| 3820007289 | |||
| 5d046ca173 |
5
dist/default.conf
vendored
@@ -17,6 +17,11 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||
|
||||
189
docs/api/rest.md
@@ -33,11 +33,90 @@ Chat upload limits:
|
||||
"providers": {
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- 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`, and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
|
||||
### `GET /v1/active-runs`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"chats": ["chat-id-with-active-stream"],
|
||||
"searches": ["search-id-with-active-stream"]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists in-memory chat/search streams that are still running on this server process.
|
||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||
- The lists are not durable across server restarts.
|
||||
|
||||
## Workspace Items
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "chat",
|
||||
"id": "chat-id",
|
||||
"title": "optional title",
|
||||
"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 up to 100 chats and up to 100 searches.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
## Chats
|
||||
|
||||
@@ -49,8 +128,10 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system|user|assistant|tool",
|
||||
@@ -66,13 +147,29 @@ Chat upload limits:
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- `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.
|
||||
|
||||
### `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.
|
||||
- `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
|
||||
@@ -85,7 +182,8 @@ Behavior notes:
|
||||
|
||||
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 a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -136,7 +234,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -164,6 +262,8 @@ Notes:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -183,20 +283,24 @@ 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.
|
||||
- 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 `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||
- For `xai`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available tool calls for chat: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, 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).
|
||||
- `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:
|
||||
@@ -211,8 +315,7 @@ 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.
|
||||
|
||||
## Searches
|
||||
|
||||
@@ -220,8 +323,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 }`
|
||||
@@ -260,6 +379,32 @@ Search run notes:
|
||||
- Persists answer text/citations + ranked results.
|
||||
- If both search and answer fail, endpoint returns an error.
|
||||
|
||||
### `POST /v1/searches/:searchId/run/stream`
|
||||
- Body: same as `POST /v1/searches/:searchId/run`
|
||||
- Response: `text/event-stream`
|
||||
|
||||
Events:
|
||||
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
|
||||
- `search_error`: `{ "error": string }`
|
||||
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
|
||||
- `answer_error`: `{ "error": string }`
|
||||
- terminal `done`: `{ "search": SearchDetail }`
|
||||
- terminal `error`: `{ "message": string }`
|
||||
|
||||
Behavior notes:
|
||||
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
|
||||
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
|
||||
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.
|
||||
|
||||
### `POST /v1/searches/:searchId/run/stream/attach`
|
||||
- Body: none
|
||||
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
|
||||
- Not found: `404 { "message": "active search stream not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.
|
||||
|
||||
## Type Shapes
|
||||
|
||||
`ChatSummary`
|
||||
@@ -269,10 +414,14 @@ Search run notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|null",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
||||
"lastUsedModel": "string|null"
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -317,17 +466,21 @@ Search run notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|null",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"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`
|
||||
@@ -338,6 +491,8 @@ Search run notes:
|
||||
"query": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"requestId": "...",
|
||||
"latencyMs": 123,
|
||||
"error": null,
|
||||
|
||||
@@ -4,6 +4,7 @@ This document defines the server-sent events (SSE) contract for chat completions
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/chat-completions/stream`
|
||||
- `POST /v1/chats/:chatId/stream/attach`
|
||||
|
||||
Transport:
|
||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||
@@ -20,7 +21,7 @@ Authentication:
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"provider": "openai|anthropic|xai",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -48,6 +49,8 @@ Authentication:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -59,8 +62,27 @@ Notes:
|
||||
- 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.
|
||||
- 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.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||
|
||||
## Attach Endpoint
|
||||
|
||||
`POST /v1/chats/:chatId/stream/attach`
|
||||
- Body: none.
|
||||
- Response uses the same `text/event-stream` transport and event names as `POST /v1/chat-completions/stream`.
|
||||
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||
- Returns `404 { "message": "active chat stream not found" }` if no stream is currently active for that chat.
|
||||
- Authentication is the same as all other API endpoints.
|
||||
|
||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||
|
||||
## Event Stream Contract
|
||||
|
||||
Event order:
|
||||
@@ -69,6 +91,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
|
||||
@@ -93,6 +117,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",
|
||||
@@ -103,11 +140,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
|
||||
@@ -133,17 +171,20 @@ 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.
|
||||
- `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.
|
||||
- `xai`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- `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.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- `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`):
|
||||
- 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.
|
||||
@@ -155,7 +196,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.
|
||||
|
||||
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||
FASTLANE_USER=you@example.com
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||
FASTLANE_HIDE_CHANGELOG=1
|
||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||
|
||||
# Optional App Store Connect API key settings for non-interactive upload and
|
||||
# TestFlight build-number lookup.
|
||||
APP_STORE_CONNECT_API_KEY_ID=
|
||||
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||
APP_STORE_CONNECT_API_KEY_PATH=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||
|
||||
# Optional deployment overrides.
|
||||
SYBIL_BUILD_NUMBER=
|
||||
SYBIL_VERSION_TAG=
|
||||
11
ios/.gitignore
vendored
@@ -1,2 +1,11 @@
|
||||
*.xcodeproj
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/
|
||||
fastlane/test_output/
|
||||
|
||||
@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- `just build` will:
|
||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
||||
- Preferred test command: `just test`
|
||||
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
|
||||
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
|
||||
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
|
||||
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
||||
|
||||
## Simulator Workflow
|
||||
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
|
||||
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
|
||||
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
|
||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||
|
||||
## App Structure
|
||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||
- Shared iOS app code lives in Swift package:
|
||||
@@ -40,3 +51,4 @@ 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`
|
||||
- Hermes Agent: `hermes-agent`
|
||||
|
||||
17
ios/Apps/Sybil/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>net.buzzert.sybil2.quick-question</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Quick question</string>
|
||||
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||
<string>sparkles</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
@@ -5,6 +5,8 @@ import UIKit
|
||||
@main
|
||||
struct SybilApp: App
|
||||
{
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
@@ -14,3 +16,79 @@ struct SybilApp: App
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let configuration = UISceneConfiguration(
|
||||
name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role
|
||||
)
|
||||
configuration.delegateClass = SybilSceneDelegate.self
|
||||
return configuration
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum SybilHomeScreenQuickActionHandler {
|
||||
static func configureQuickActions() {
|
||||
// The quick question action is static in Info.plist so it is available before first launch.
|
||||
UIApplication.shared.shortcutItems = []
|
||||
}
|
||||
|
||||
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||
guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
|
||||
return false
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ targets:
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.4
|
||||
CURRENT_PROJECT_VERSION: 5
|
||||
MARKETING_VERSION: "1.10"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.227"
|
||||
@@ -2,10 +2,15 @@ import SwiftUI
|
||||
|
||||
public struct SplitView: View {
|
||||
@State private var viewModel = SybilViewModel()
|
||||
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
@State private var quickQuestionFocusRequest = 0
|
||||
@State private var hasPendingQuickQuestionPresentation = false
|
||||
@State private var isQuickQuestionPresented = false
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||
@@ -50,46 +55,109 @@ public struct SplitView: View {
|
||||
} else if horizontalSizeClass == .compact {
|
||||
SybilPhoneShellView(viewModel: viewModel)
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
GeometryReader { proxy in
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
||||
onShowSidebar: showSidebar,
|
||||
onRequestNewChat: {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
focusRequest: quickQuestionFocusRequest
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.task {
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
||||
guard request > 0 else {
|
||||
return
|
||||
}
|
||||
queueQuickQuestionPresentation()
|
||||
}
|
||||
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
}
|
||||
shouldRefreshOnForeground = false
|
||||
Task {
|
||||
await viewModel.refreshVisibleContent(
|
||||
await viewModel.refreshAfterAppBecameActive(
|
||||
refreshCollections: true,
|
||||
refreshSelection: viewModel.hasRefreshableSelection
|
||||
)
|
||||
}
|
||||
case .inactive:
|
||||
break
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
||||
return size.width < size.height ? .showSidebar : .hidden
|
||||
}
|
||||
|
||||
private func showSidebar() {
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
columnVisibility = .all
|
||||
}
|
||||
}
|
||||
|
||||
private func queueQuickQuestionPresentation() {
|
||||
hasPendingQuickQuestionPresentation = true
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
|
||||
private func presentPendingQuickQuestionIfPossible() {
|
||||
guard hasPendingQuickQuestionPresentation,
|
||||
!viewModel.isCheckingSession,
|
||||
viewModel.isAuthenticated
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
viewModel.cancelQuickQuestion()
|
||||
}
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
|
||||
@@ -44,16 +44,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
|
||||
return response.items
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
}
|
||||
|
||||
func createChat(title: String? = nil) async throws -> ChatSummary {
|
||||
func createChat(
|
||||
title: String? = nil,
|
||||
provider: Provider? = nil,
|
||||
model: String? = nil,
|
||||
messages: [CompletionRequestMessage]? = nil
|
||||
) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatCreateBody(title: title)),
|
||||
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
@@ -64,6 +74,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -108,6 +138,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)
|
||||
}
|
||||
@@ -116,6 +156,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||
}
|
||||
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
|
||||
}
|
||||
|
||||
func runCompletionStream(
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
@@ -133,43 +177,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
switch eventName {
|
||||
case "meta":
|
||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||
await onEvent(.meta(payload))
|
||||
case "tool_call":
|
||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||
await onEvent(.toolCall(payload))
|
||||
case "delta":
|
||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||
await onEvent(.delta(payload))
|
||||
case "done":
|
||||
do {
|
||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||
await onEvent(.done(payload))
|
||||
} catch {
|
||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||
SybilLog.warning(
|
||||
SybilLog.network,
|
||||
"Recovered chat stream done payload from concatenated SSE data"
|
||||
)
|
||||
await onEvent(.done(recovered))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
case "error":
|
||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.error(payload))
|
||||
default:
|
||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||
await onEvent(.ignored)
|
||||
}
|
||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||
}
|
||||
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
let request = try makeRequest(
|
||||
path: "/v1/chats/\(chatID)/stream/attach",
|
||||
method: "POST",
|
||||
body: nil,
|
||||
acceptsSSE: true
|
||||
)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.network,
|
||||
"Attaching chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Attached chat stream completed")
|
||||
}
|
||||
|
||||
func runSearchStream(
|
||||
searchID: String,
|
||||
body: SearchRunRequest,
|
||||
@@ -188,34 +224,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
switch eventName {
|
||||
case "search_results":
|
||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||
await onEvent(.searchResults(payload))
|
||||
case "search_error":
|
||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.searchError(payload))
|
||||
case "answer":
|
||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||
await onEvent(.answer(payload))
|
||||
case "answer_error":
|
||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.answerError(payload))
|
||||
case "done":
|
||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||
await onEvent(.done(payload))
|
||||
case "error":
|
||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.error(payload))
|
||||
default:
|
||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||
await onEvent(.ignored)
|
||||
}
|
||||
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||
}
|
||||
|
||||
func attachSearchStream(
|
||||
searchID: String,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
let request = try makeRequest(
|
||||
path: "/v1/searches/\(searchID)/run/stream/attach",
|
||||
method: "POST",
|
||||
body: nil,
|
||||
acceptsSSE: true
|
||||
)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.network,
|
||||
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Attached search stream completed")
|
||||
}
|
||||
|
||||
private func request<Response: Decodable>(
|
||||
_ path: String,
|
||||
method: String,
|
||||
@@ -498,6 +535,75 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return try? Self.decodeJSON(type, from: data)
|
||||
}
|
||||
|
||||
private static func handleCompletionStreamEvent(
|
||||
eventName: String,
|
||||
dataText: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
switch eventName {
|
||||
case "meta":
|
||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||
await onEvent(.meta(payload))
|
||||
case "tool_call":
|
||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||
await onEvent(.toolCall(payload))
|
||||
case "delta":
|
||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||
await onEvent(.delta(payload))
|
||||
case "done":
|
||||
do {
|
||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||
await onEvent(.done(payload))
|
||||
} catch {
|
||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||
SybilLog.warning(
|
||||
SybilLog.network,
|
||||
"Recovered chat stream done payload from concatenated SSE data"
|
||||
)
|
||||
await onEvent(.done(recovered))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
case "error":
|
||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.error(payload))
|
||||
default:
|
||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||
await onEvent(.ignored)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleSearchStreamEvent(
|
||||
eventName: String,
|
||||
dataText: String,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
switch eventName {
|
||||
case "search_results":
|
||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||
await onEvent(.searchResults(payload))
|
||||
case "search_error":
|
||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.searchError(payload))
|
||||
case "answer":
|
||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||
await onEvent(.answer(payload))
|
||||
case "answer_error":
|
||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.answerError(payload))
|
||||
case "done":
|
||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||
await onEvent(.done(payload))
|
||||
case "error":
|
||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||
await onEvent(.error(payload))
|
||||
default:
|
||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||
await onEvent(.ignored)
|
||||
}
|
||||
}
|
||||
|
||||
private static func flushSSEEvent(
|
||||
eventName: inout String,
|
||||
dataLines: inout [String]
|
||||
@@ -551,13 +657,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
|
||||
struct CompletionStreamRequest: Codable, Sendable {
|
||||
var chatId: String?
|
||||
var persist: Bool? = nil
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
var provider: Provider?
|
||||
var model: String?
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
private struct SearchCreateBody: Encodable {
|
||||
|
||||
@@ -2,24 +2,48 @@ import Foundation
|
||||
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(title: String?) async throws -> ChatSummary
|
||||
func createChat(
|
||||
title: String?,
|
||||
provider: Provider?,
|
||||
model: String?,
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||
func listSearches() async throws -> [SearchSummary]
|
||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||
func getSearch(searchID: String) async throws -> SearchDetail
|
||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
||||
func deleteSearch(searchID: String) async throws
|
||||
func listModels() async throws -> ModelCatalogResponse
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||
func runCompletionStream(
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func runSearchStream(
|
||||
searchID: String,
|
||||
body: SearchRunRequest,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws
|
||||
func attachSearchStream(
|
||||
searchID: String,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws
|
||||
}
|
||||
|
||||
extension SybilAPIClienting {
|
||||
func createChat(title: String?) async throws -> ChatSummary {
|
||||
try await createChat(title: title, provider: nil, model: nil, messages: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,26 @@ struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
@State private var hasHandledInitialTranscriptScroll = false
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
|
||||
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 {
|
||||
@@ -25,60 +38,103 @@ struct SybilChatTranscriptView: View {
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(message.id)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
groupID: id,
|
||||
messages: messages,
|
||||
entryAnimationIDs: enteringToolCallMessageIDs
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(id)
|
||||
}
|
||||
|
||||
if isSending && !hasPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text("Assistant is typing…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.id("typing-indicator")
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 2)
|
||||
.id("chat-bottom-anchor")
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.refreshable {
|
||||
await onRefresh?()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
syncKnownToolCallMessageIDs()
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
}
|
||||
.onChange(of: messages.map(\.id)) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
|
||||
hasHandledInitialTranscriptScroll = true
|
||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||
syncKnownToolCallMessageIDs()
|
||||
}
|
||||
.onChange(of: isSending) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
|
||||
.onChange(of: bottomPinRequestID) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||
let action = {
|
||||
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.22)) {
|
||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.18), action)
|
||||
} else {
|
||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncKnownToolCallMessageIDs() {
|
||||
guard !toolCallMessageIDs.isEmpty else { return }
|
||||
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
||||
hasTrackedToolCallMessages = true
|
||||
}
|
||||
}
|
||||
|
||||
enum TranscriptRenderItem: Identifiable {
|
||||
case message(Message)
|
||||
case toolGroup(id: String, messages: [Message])
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
return message.id
|
||||
case let .toolGroup(id, _):
|
||||
return "tool-group-\(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
|
||||
var items: [TranscriptRenderItem] = []
|
||||
var toolRun: [Message] = []
|
||||
|
||||
func flushToolRun() {
|
||||
guard !toolRun.isEmpty else { return }
|
||||
if toolRun.count == 1, let message = toolRun.first {
|
||||
items.append(.message(message))
|
||||
} else if let first = toolRun.first {
|
||||
items.append(.toolGroup(id: first.id, messages: toolRun))
|
||||
}
|
||||
toolRun.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.toolCallMetadata != nil {
|
||||
toolRun.append(message)
|
||||
} else {
|
||||
flushToolRun()
|
||||
items.append(.message(message))
|
||||
}
|
||||
}
|
||||
|
||||
flushToolRun()
|
||||
return items
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
@@ -137,6 +193,7 @@ private struct MessageBubble: View {
|
||||
}
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
.textSelection(.enabled)
|
||||
.background(
|
||||
Group {
|
||||
if isUser {
|
||||
@@ -175,10 +232,225 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
var y: CGFloat
|
||||
var scale: CGFloat
|
||||
var opacity: Double
|
||||
var zIndex: Double
|
||||
}
|
||||
|
||||
var groupID: String
|
||||
var messages: [Message]
|
||||
var entryAnimationIDs: Set<String>
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isExpanded = false
|
||||
|
||||
private let visibleCollapsedLimit = 4
|
||||
private let cardHeight: CGFloat = 62
|
||||
private let expandedGap: CGFloat = 10
|
||||
private let collapsedStepX: CGFloat = 11
|
||||
private let collapsedStepY: CGFloat = 10
|
||||
private let toggleSize: CGFloat = 32
|
||||
private let toggleGap: CGFloat = 12
|
||||
|
||||
private var animation: Animation? {
|
||||
reduceMotion ? nil : .easeInOut(duration: 0.34)
|
||||
}
|
||||
|
||||
private var visibleCollapsedCount: Int {
|
||||
min(messages.count, visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var hiddenCount: Int {
|
||||
max(0, messages.count - visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var containerHeight: CGFloat {
|
||||
if isExpanded {
|
||||
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
|
||||
}
|
||||
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
GeometryReader { geometry in
|
||||
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
|
||||
let toggleX = cardWidth + toggleGap
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
||||
let layout = layout(for: index)
|
||||
let depth = messages.count - index - 1
|
||||
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
||||
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
||||
|
||||
ToolCallStackCard(
|
||||
message: message,
|
||||
cardHeight: cardHeight,
|
||||
compactLayout: true,
|
||||
animateEntry: shouldAnimateEntry
|
||||
)
|
||||
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
||||
.scaleEffect(layout.scale, anchor: .topLeading)
|
||||
.opacity(layout.opacity)
|
||||
.offset(x: layout.x, y: layout.y)
|
||||
.zIndex(layout.zIndex)
|
||||
.allowsHitTesting(!isHidden)
|
||||
.accessibilityHidden(isHidden)
|
||||
}
|
||||
|
||||
if !isExpanded && hiddenCount > 0 {
|
||||
Text("+\(hiddenCount)")
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.58))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(animation) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.frame(width: toggleSize, height: toggleSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
|
||||
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
|
||||
.offset(x: toggleX, y: 8)
|
||||
.zIndex(Double(messages.count + 2))
|
||||
}
|
||||
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
|
||||
.animation(animation, value: isExpanded)
|
||||
}
|
||||
.frame(height: containerHeight)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func layout(for index: Int) -> CardLayout {
|
||||
if isExpanded {
|
||||
return CardLayout(
|
||||
x: 0,
|
||||
y: CGFloat(index) * (cardHeight + expandedGap),
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
zIndex: Double(messages.count - index)
|
||||
)
|
||||
}
|
||||
|
||||
let depth = messages.count - index - 1
|
||||
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
|
||||
let isHidden = depth >= visibleCollapsedLimit
|
||||
return CardLayout(
|
||||
x: CGFloat(visibleDepth) * collapsedStepX,
|
||||
y: CGFloat(visibleDepth) * collapsedStepY,
|
||||
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
|
||||
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
|
||||
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackCard: View {
|
||||
var message: Message
|
||||
var cardHeight: CGFloat
|
||||
var compactLayout: Bool
|
||||
var animateEntry: Bool
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var entryAnimationArmed = false
|
||||
@State private var didEnter = false
|
||||
|
||||
private var isPreparingEntry: Bool {
|
||||
(animateEntry || entryAnimationArmed) && !didEnter
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let metadata = message.toolCallMetadata {
|
||||
ToolCallActivityChip(
|
||||
metadata: metadata,
|
||||
fallbackContent: message.content,
|
||||
createdAt: message.createdAt,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: cardHeight, alignment: .top)
|
||||
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
|
||||
.offset(y: isPreparingEntry ? -8 : 0)
|
||||
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||
.opacity(isPreparingEntry ? 0.72 : 1)
|
||||
.onAppear {
|
||||
guard !didEnter, !entryAnimationArmed else { return }
|
||||
guard animateEntry else {
|
||||
didEnter = true
|
||||
return
|
||||
}
|
||||
entryAnimationArmed = true
|
||||
if reduceMotion {
|
||||
didEnter = true
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
||||
didEnter = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
var compactLayout: Bool = false
|
||||
|
||||
private var summary: String {
|
||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
@@ -222,11 +494,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")
|
||||
}
|
||||
@@ -238,14 +521,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)
|
||||
|
||||
@@ -254,12 +537,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)
|
||||
@@ -269,16 +554,50 @@ private struct ToolCallActivityChip: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ extension Theme {
|
||||
.paragraph { configuration in
|
||||
configuration.label
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.relativeLineSpacing(.em(0.36))
|
||||
.relativeLineSpacing(.em(0.46))
|
||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||
}
|
||||
.blockquote { configuration in
|
||||
|
||||
@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case openai
|
||||
case anthropic
|
||||
case xai
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .openai: return "OpenAI"
|
||||
case .anthropic: return "Anthropic"
|
||||
case .xai: return "xAI"
|
||||
case .hermesAgent: return "Hermes Agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +154,8 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
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?
|
||||
@@ -164,6 +168,87 @@ public struct SearchSummary: 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 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 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.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.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,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
@@ -306,6 +391,8 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
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?
|
||||
@@ -344,6 +431,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?
|
||||
@@ -354,6 +443,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var results: [SearchResultItem]
|
||||
}
|
||||
|
||||
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
|
||||
public var chats: [String]
|
||||
public var searches: [String]
|
||||
|
||||
public init(chats: [String] = [], searches: [String] = []) {
|
||||
self.chats = chats
|
||||
self.searches = searches
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchRunRequest: Codable, Sendable {
|
||||
public var query: String?
|
||||
public var title: String?
|
||||
@@ -394,8 +493,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct CompletionStreamMeta: Codable, Sendable {
|
||||
public var chatId: String
|
||||
public var callId: String
|
||||
public var chatId: String?
|
||||
public var callId: String?
|
||||
public var provider: Provider
|
||||
public var model: String
|
||||
}
|
||||
@@ -415,8 +514,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?
|
||||
}
|
||||
@@ -512,6 +611,10 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
struct ChatDetailResponse: Codable {
|
||||
var chat: ChatDetail
|
||||
}
|
||||
|
||||
@@ -22,58 +22,473 @@ enum PhoneRoute: Hashable {
|
||||
|
||||
struct SybilPhoneShellView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@State private var path: [PhoneRoute] = []
|
||||
@State private var route: PhoneRoute = .draftChat
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
||||
@State private var isSidebarOverlayPresented = false
|
||||
@State private var sidebarSwipeOffset: CGFloat = 0
|
||||
@State private var sidebarSwipeIsActive = false
|
||||
@State private var sidebarSwipeIsCompleting = false
|
||||
@State private var sidebarSwipeHasLatched = false
|
||||
@State private var sidebarHighlightSelection: SidebarSelection?
|
||||
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||
@State private var openingSelectionRequestID: UUID?
|
||||
|
||||
private var canRecognizeSidebarSwipe: Bool {
|
||||
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||
}
|
||||
|
||||
private var sidebarOverlayProgress: CGFloat {
|
||||
if isSidebarOverlayPresented {
|
||||
return 1
|
||||
}
|
||||
|
||||
return SidebarOverlaySwipeMetrics.progress(
|
||||
for: sidebarSwipeOffset,
|
||||
width: phoneStackWidth
|
||||
)
|
||||
}
|
||||
|
||||
private var shouldRenderSidebarOverlay: Bool {
|
||||
isSidebarOverlayPresented ||
|
||||
sidebarSwipeIsActive ||
|
||||
sidebarSwipeIsCompleting ||
|
||||
sidebarOverlayProgress > 0.001
|
||||
}
|
||||
|
||||
private var currentRouteSelection: SidebarSelection? {
|
||||
switch route {
|
||||
case let .chat(chatID):
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var highlightedSidebarSelection: SidebarSelection? {
|
||||
sidebarHighlightSelection ?? currentRouteSelection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
GeometryReader { proxy in
|
||||
phoneStack(width: proxy.size.width)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
updatePhoneStackWidth(proxy.size.width)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
path: $path,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
route: route
|
||||
)
|
||||
.onChange(of: proxy.size.width) { _, width in
|
||||
updatePhoneStackWidth(width)
|
||||
}
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground else {
|
||||
return
|
||||
}
|
||||
shouldRefreshOnForeground = false
|
||||
Task {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: path.isEmpty,
|
||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||
await viewModel.refreshAfterAppBecameActive(
|
||||
refreshCollections: isSidebarOverlayPresented,
|
||||
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
||||
)
|
||||
}
|
||||
case .inactive:
|
||||
break
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func phoneStack(width: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
phoneWorkspaceLayer
|
||||
.zIndex(0)
|
||||
|
||||
phoneSidebarOverlayLayer(width: width)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneWorkspaceLayer: some View {
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
|
||||
.allowsHitTesting(!shouldRenderSidebarOverlay)
|
||||
.background {
|
||||
sidebarSwipeInstaller
|
||||
}
|
||||
}
|
||||
|
||||
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
phoneOverlayTopBar
|
||||
|
||||
SybilPhoneSidebarRoot(
|
||||
viewModel: viewModel,
|
||||
highlightedSelection: highlightedSidebarSelection,
|
||||
onSelect: openSidebarSelection,
|
||||
onRoute: showRouteAndClearSidebarHighlight
|
||||
)
|
||||
}
|
||||
.opacity(sidebarOverlayProgress)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
|
||||
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
|
||||
.allowsHitTesting(isSidebarOverlayPresented)
|
||||
.accessibilityHidden(!isSidebarOverlayPresented)
|
||||
}
|
||||
|
||||
private var sidebarSwipeInstaller: some View {
|
||||
WorkspaceSwipePanInstaller(
|
||||
direction: .right,
|
||||
isEnabled: canRecognizeSidebarSwipe,
|
||||
onBegan: { width in
|
||||
beginSidebarSwipe(containerWidth: width)
|
||||
},
|
||||
onChanged: { translationX, width in
|
||||
updateSidebarSwipe(with: translationX, containerWidth: width)
|
||||
},
|
||||
onEnded: { translationX, width, velocityX, didFinish in
|
||||
finishSidebarSwipe(
|
||||
translationX: translationX,
|
||||
containerWidth: width,
|
||||
velocityX: velocityX,
|
||||
didFinish: didFinish
|
||||
)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
|
||||
guard !isSidebarOverlayPresented else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return {
|
||||
startNewChatFromDestination()
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneOverlayTopBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
SybilWordmark(size: 21)
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
hideSidebarOverlay()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.2")
|
||||
.font(.system(size: 21, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface.opacity(0.76))
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Hide conversations")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background {
|
||||
SybilPhoneOverlayBlurBand(edge: .top)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePhoneStackWidth(_ width: CGFloat) {
|
||||
phoneStackWidth = max(width, 1)
|
||||
}
|
||||
|
||||
private func startNewChatFromDestination() {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
showRoute(.draftChat)
|
||||
}
|
||||
|
||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||
let update = {
|
||||
route = nextRoute
|
||||
}
|
||||
|
||||
if isSidebarOverlayPresented {
|
||||
withAnimation(.easeOut(duration: 0.22)) {
|
||||
update()
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
||||
showRoute(nextRoute)
|
||||
clearSidebarHighlight()
|
||||
}
|
||||
|
||||
private func showSidebarOverlay() {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = true
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func hideSidebarOverlay() {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||
return
|
||||
}
|
||||
|
||||
let requestID = UUID()
|
||||
openingSelectionRequestID = requestID
|
||||
setSidebarHighlight(selection)
|
||||
|
||||
Task {
|
||||
await viewModel.selectForNavigation(selection)
|
||||
guard openingSelectionRequestID == requestID else {
|
||||
return
|
||||
}
|
||||
|
||||
showRoute(PhoneRoute.from(selection: selection))
|
||||
openingSelectionRequestID = nil
|
||||
clearSidebarHighlight(selection, after: .milliseconds(260))
|
||||
}
|
||||
}
|
||||
|
||||
private func setSidebarHighlight(_ selection: SidebarSelection) {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
sidebarHighlightSelection = selection
|
||||
}
|
||||
|
||||
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
sidebarHighlightClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: delay)
|
||||
guard !Task.isCancelled,
|
||||
sidebarHighlightSelection == selection,
|
||||
openingSelectionRequestID == nil else {
|
||||
return
|
||||
}
|
||||
sidebarHighlightSelection = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSidebarHighlight() {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
openingSelectionRequestID = nil
|
||||
sidebarHighlightSelection = nil
|
||||
}
|
||||
|
||||
private func beginSidebarSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
phoneStackWidth = max(containerWidth, 1)
|
||||
sidebarSwipeIsActive = true
|
||||
sidebarSwipeHasLatched = false
|
||||
}
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction, update)
|
||||
}
|
||||
|
||||
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||
offset: nextOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||
)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
phoneStackWidth = max(containerWidth, 1)
|
||||
sidebarSwipeOffset = nextOffset
|
||||
sidebarSwipeHasLatched = nextLatched
|
||||
}
|
||||
}
|
||||
|
||||
private func finishSidebarSwipe(
|
||||
translationX: CGFloat,
|
||||
containerWidth: CGFloat,
|
||||
velocityX: CGFloat,
|
||||
didFinish: Bool
|
||||
) {
|
||||
guard sidebarSwipeIsActive else {
|
||||
resetSidebarSwipe(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||
offset: finalOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||
)
|
||||
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
||||
|
||||
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
||||
offset: finalOffset,
|
||||
velocityX: velocityX,
|
||||
width: containerWidth,
|
||||
isLatched: finalLatched
|
||||
) {
|
||||
completeSidebarSwipe()
|
||||
return
|
||||
}
|
||||
|
||||
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
||||
}
|
||||
|
||||
private func completeSidebarSwipe() {
|
||||
guard !sidebarSwipeIsCompleting else {
|
||||
return
|
||||
}
|
||||
|
||||
sidebarSwipeIsCompleting = true
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = true
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||
let currentOffset = sidebarSwipeOffset
|
||||
let reset = {
|
||||
sidebarSwipeOffset = 0
|
||||
sidebarSwipeIsActive = false
|
||||
sidebarSwipeIsCompleting = false
|
||||
sidebarSwipeHasLatched = false
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(
|
||||
SidebarOverlaySwipeMetrics.springAnimation(
|
||||
currentOffset: currentOffset,
|
||||
targetOffset: 0,
|
||||
velocityX: velocityX
|
||||
)
|
||||
) {
|
||||
reset()
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SidebarOverlaySwipeMetrics {
|
||||
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
||||
}
|
||||
|
||||
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
BackSwipeMetrics.progress(for: offset, width: width)
|
||||
}
|
||||
|
||||
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||
}
|
||||
|
||||
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
||||
}
|
||||
|
||||
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
||||
}
|
||||
|
||||
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
|
||||
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
|
||||
}
|
||||
|
||||
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||
(1 - min(max(progress, 0), 1)) * 18
|
||||
}
|
||||
|
||||
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||
min(max(progress, 0), 1) * 14
|
||||
}
|
||||
|
||||
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
|
||||
1 - (min(max(progress, 0), 1) * 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneOverlayBlurBand: View {
|
||||
var edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.opacity(0.34)
|
||||
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: gradientColors,
|
||||
startPoint: edge == .top ? .top : .bottom,
|
||||
endPoint: edge == .top ? .bottom : .top
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var gradientColors: [Color] {
|
||||
[
|
||||
Color.black.opacity(0.94),
|
||||
SybilTheme.background.opacity(0.78),
|
||||
Color.black.opacity(0)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneSidebarRoot: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Binding var path: [PhoneRoute]
|
||||
var highlightedSelection: SidebarSelection?
|
||||
var onSelect: (SidebarSelection) -> Void
|
||||
var onRoute: (PhoneRoute) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -89,54 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
.overlay(SybilTheme.border)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
||||
SybilPhoneSidebarRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshCollectionsFromUser()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
SybilSidebarItemList(
|
||||
viewModel: viewModel,
|
||||
isSelected: { item in
|
||||
highlightedSelection == item.selection
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -151,19 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
|
||||
HStack(spacing: 12) {
|
||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||
path = [.settings]
|
||||
viewModel.openSettings()
|
||||
onRoute(.settings)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||
viewModel.startNewSearch()
|
||||
path = [.draftSearch]
|
||||
onRoute(.draftSearch)
|
||||
}
|
||||
|
||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||
viewModel.startNewChat()
|
||||
path = [.draftChat]
|
||||
onRoute(.draftChat)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
@@ -201,79 +578,24 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(SybilTheme.surface.opacity(0.72))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
Text(item.title)
|
||||
.font(.sybil(.subheadline, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
if let initiated = item.initiatedLabel {
|
||||
Spacer(minLength: 0)
|
||||
Text(initiated)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneDestinationView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Binding var path: [PhoneRoute]
|
||||
@Binding var composerFocusRequest: Int
|
||||
let route: PhoneRoute
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
path = [.draftChat]
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = .draftChat
|
||||
}
|
||||
}
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
applyRoute()
|
||||
}
|
||||
@@ -282,8 +604,14 @@ private struct SybilPhoneDestinationView: View {
|
||||
private func applyRoute() {
|
||||
switch route {
|
||||
case let .chat(chatID):
|
||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
||||
return
|
||||
}
|
||||
viewModel.select(.chat(chatID))
|
||||
case let .search(searchID):
|
||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
public enum SybilHomeScreenQuickAction {
|
||||
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class SybilQuickActionRouter: ObservableObject {
|
||||
public static let shared = SybilQuickActionRouter()
|
||||
|
||||
@Published public private(set) var quickQuestionPresentationRequest = 0
|
||||
|
||||
private init() {}
|
||||
|
||||
public func requestQuickQuestionPresentation() {
|
||||
quickQuestionPresentationRequest += 1
|
||||
}
|
||||
}
|
||||
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import MarkdownUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SybilQuickQuestionView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var focusRequest: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
|
||||
answerArea
|
||||
|
||||
composer
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 12)
|
||||
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(SybilTheme.backgroundGradient)
|
||||
.preferredColorScheme(.dark)
|
||||
.task(id: focusRequest) {
|
||||
try? await Task.sleep(for: .milliseconds(260))
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 21, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
|
||||
Text("Quick question")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var answerArea: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if hasAnswerContent {
|
||||
ForEach(viewModel.quickQuestionMessages) { message in
|
||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
if let error = viewModel.quickQuestionError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(14)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.black.opacity(0.36))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
TextField(
|
||||
"Ask anything...",
|
||||
text: Binding(
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($promptFocused)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submitQuestion)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
Button(action: submitQuestion) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendQuickQuestion
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendQuickQuestion)
|
||||
.accessibilityLabel("Ask quick question")
|
||||
}
|
||||
|
||||
controlsRow
|
||||
}
|
||||
}
|
||||
|
||||
private var convertButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||
if didConvert {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Chat", systemImage: "bubble.left")
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.disabled(!viewModel.canConvertQuickQuestion)
|
||||
}
|
||||
|
||||
private var controlsRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
providerMenu
|
||||
modelMenu
|
||||
convertButton
|
||||
}
|
||||
}
|
||||
|
||||
private var providerMenu: some View {
|
||||
Menu {
|
||||
ForEach(viewModel.providerOptions, id: \.self) { provider in
|
||||
Button {
|
||||
viewModel.setQuickQuestionProvider(provider)
|
||||
} label: {
|
||||
if viewModel.quickQuestionProvider == provider {
|
||||
Label(provider.displayName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(provider.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||
.accessibilityLabel("Quick question provider")
|
||||
}
|
||||
|
||||
private var modelMenu: some View {
|
||||
Menu {
|
||||
if viewModel.quickQuestionProviderModelOptions.isEmpty {
|
||||
Text("No models")
|
||||
} else {
|
||||
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
|
||||
Button {
|
||||
viewModel.setQuickQuestionModel(model)
|
||||
} label: {
|
||||
if viewModel.quickQuestionModel == model {
|
||||
Label(model, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||
.accessibilityLabel("Quick question model")
|
||||
}
|
||||
|
||||
private func submitQuestion() {
|
||||
guard viewModel.canSendQuickQuestion else {
|
||||
return
|
||||
}
|
||||
|
||||
promptFocused = false
|
||||
_ = viewModel.sendQuickQuestion()
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuickQuestionPickerPill: View {
|
||||
var title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuickQuestionMessageView: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let metadata = message.toolCallMetadata {
|
||||
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.font(.body)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.96))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
|
||||
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
|
||||
return summary
|
||||
}
|
||||
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return fallbackContent
|
||||
}
|
||||
return "Ran \(metadata.toolName ?? "tool")."
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ struct SybilSearchResultsView: View {
|
||||
var isLoading: Bool
|
||||
var isRunning: Bool
|
||||
var isStartingChat: Bool = false
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var onStartChat: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -99,12 +100,9 @@ struct SybilSearchResultsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 20)
|
||||
.padding(.top, 20 + topContentInset)
|
||||
.padding(.bottom, 20 + bottomContentInset)
|
||||
}
|
||||
.refreshable {
|
||||
await onRefresh?()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ final class SybilSettingsStore {
|
||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
|
||||
var adminToken: String
|
||||
var preferredProvider: Provider
|
||||
var preferredModelByProvider: [Provider: String]
|
||||
var quickQuestionPreferredProvider: Provider
|
||||
var quickQuestionPreferredModelByProvider: [Provider: String]
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
|
||||
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
self.preferredProvider = provider
|
||||
|
||||
self.preferredModelByProvider = [
|
||||
let preferredModels: [Provider: String] = [
|
||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
|
||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||
]
|
||||
self.preferredModelByProvider = preferredModels
|
||||
|
||||
self.quickQuestionPreferredProvider =
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||
self.quickQuestionPreferredModelByProvider = [
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,6 +72,13 @@ final class SybilSettingsStore {
|
||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||
|
||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||
}
|
||||
|
||||
var trimmedTokenOrNil: String? {
|
||||
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
|
||||
raw.removeLast()
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: raw) else {
|
||||
guard let components = URLComponents(string: raw) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ import SwiftUI
|
||||
struct SybilSidebarView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
|
||||
private func iconName(for item: SidebarItem) -> String {
|
||||
switch item.kind {
|
||||
case .chat: return "message"
|
||||
case .search: return "globe"
|
||||
}
|
||||
}
|
||||
|
||||
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||
}
|
||||
@@ -57,103 +50,13 @@ struct SybilSidebarView: View {
|
||||
.overlay(SybilTheme.border)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
SybilSidebarItemList(
|
||||
viewModel: viewModel,
|
||||
isSelected: isSelected,
|
||||
onSelect: { item in
|
||||
viewModel.select(item.selection)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: iconName(for: item))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
Text(item.title)
|
||||
.font(.sybil(.subheadline, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
if let initiated = item.initiatedLabel {
|
||||
Spacer(minLength: 0)
|
||||
Text(initiated)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshCollectionsFromUser()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -203,3 +106,206 @@ struct SybilSidebarView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
renameTarget != nil
|
||||
} set: { isPresented in
|
||||
if !isPresented {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
||||
}
|
||||
} label: {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||
TextField("Title", text: $renameTitle)
|
||||
Button("Cancel", role: .cancel) {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
Button("Save") {
|
||||
let target = renameTarget
|
||||
let title = renameTitle
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
|
||||
if let target, case let .chat(chatID) = target.selection {
|
||||
Task {
|
||||
await viewModel.renameChat(chatID: chatID, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
var isSelected: Bool
|
||||
|
||||
private var isHighlighted: Bool {
|
||||
isSelected
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch item.kind {
|
||||
case .chat: return "message"
|
||||
case .search: return "globe"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
Text(item.title)
|
||||
.font(.sybil(.subheadline, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
if item.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if item.isRunning {
|
||||
SybilSidebarActivityIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
if let initiated = item.initiatedLabel {
|
||||
Spacer(minLength: 0)
|
||||
Text(initiated)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarActivityIndicator: View {
|
||||
var body: some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.accent)
|
||||
.scaleEffect(0.82)
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityLabel("Generating")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
||||
}
|
||||
|
||||
private static let registeredFonts: Void = {
|
||||
for fontName in ["Inter", "Orbitron"] {
|
||||
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||
else {
|
||||
@@ -78,6 +78,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 +179,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 +201,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
|
||||
@@ -203,7 +215,7 @@ struct SybilWordmark: View {
|
||||
|
||||
var body: some View {
|
||||
Text("SYBIL")
|
||||
.font(.custom("Orbitron", size: size))
|
||||
.font(.custom("Stalinist One", size: size))
|
||||
.fontWeight(.black)
|
||||
.tracking(0)
|
||||
.foregroundStyle(SybilTheme.brandGradient)
|
||||
|
||||
@@ -4,10 +4,26 @@ import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listWorkspaceItems = 0
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var updateChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
var updateSearchStar = 0
|
||||
var getSearch = 0
|
||||
var getActiveRuns = 0
|
||||
var runCompletionStream = 0
|
||||
var attachCompletionStream = 0
|
||||
var attachSearchStream = 0
|
||||
}
|
||||
|
||||
private struct ChatCreateCallSnapshot: Sendable {
|
||||
var title: String?
|
||||
var provider: Provider?
|
||||
var model: String?
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct UnexpectedClientCall: Error {}
|
||||
@@ -15,48 +31,185 @@ private struct UnexpectedClientCall: Error {}
|
||||
private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let workspaceItemsResponse: [WorkspaceItem]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
private let updateSearchStarResponses: [String: SearchSummary]
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
|
||||
private var snapshot = MockClientCallSnapshot()
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||
private var completionStreamNetworkErrorMessage: String?
|
||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||
private var searchStreamNetworkErrorMessage: String?
|
||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||
private var searchAttachDelayNanoseconds: UInt64 = 0
|
||||
|
||||
init(
|
||||
chatsResponse: [ChatSummary] = [],
|
||||
searchesResponse: [SearchSummary] = [],
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:]
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
self.updateSearchStarResponses = updateSearchStarResponses
|
||||
self.activeRunsResponse = activeRunsResponse
|
||||
}
|
||||
|
||||
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
||||
(chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
func currentSnapshot() -> MockClientCallSnapshot {
|
||||
snapshot
|
||||
}
|
||||
|
||||
func currentCreateChatCall() -> ChatCreateCallSnapshot? {
|
||||
lastCreateChatCall
|
||||
}
|
||||
|
||||
func currentCompletionStreamBody() -> CompletionStreamRequest? {
|
||||
lastCompletionStreamBody
|
||||
}
|
||||
|
||||
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
||||
completionStreamEvents = events
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||
completionStreamNetworkErrorMessage = message
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||
listChatsDelayNanoseconds = chats
|
||||
listSearchesDelayNanoseconds = searches
|
||||
}
|
||||
|
||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||
getSearchDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||
searchStreamNetworkErrorMessage = message
|
||||
searchStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setCompletionAttachEvents(
|
||||
chatID: String,
|
||||
events: [CompletionStreamEvent],
|
||||
delayNanoseconds: UInt64 = 0
|
||||
) {
|
||||
completionAttachEvents[chatID] = events
|
||||
completionAttachDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setSearchAttachEvents(
|
||||
searchID: String,
|
||||
events: [SearchStreamEvent],
|
||||
delayNanoseconds: UInt64 = 0
|
||||
) {
|
||||
searchAttachEvents[searchID] = events
|
||||
searchAttachDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
AuthSession(authenticated: true, mode: "open")
|
||||
}
|
||||
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||
snapshot.listWorkspaceItems += 1
|
||||
let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
|
||||
if delay > 0 {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
}
|
||||
return workspaceItemsResponse
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||
}
|
||||
return chatsResponse
|
||||
}
|
||||
|
||||
func createChat(title: String?) async throws -> ChatSummary {
|
||||
func createChat(
|
||||
title: String?,
|
||||
provider: Provider?,
|
||||
model: String?,
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary {
|
||||
snapshot.createChat += 1
|
||||
lastCreateChatCall = ChatCreateCallSnapshot(
|
||||
title: title,
|
||||
provider: provider,
|
||||
model: model,
|
||||
messages: messages
|
||||
)
|
||||
if let createChatResponse {
|
||||
return createChatResponse
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func getChat(chatID: String) async throws -> ChatDetail {
|
||||
snapshot.getChat += 1
|
||||
if getChatDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||
}
|
||||
guard let detail = chatDetails[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
snapshot.updateChatTitle += 1
|
||||
guard let summary = updateChatTitleResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
snapshot.updateChatStar += 1
|
||||
guard let summary = updateChatStarResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -67,6 +220,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
if listSearchesDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||
}
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
@@ -76,6 +232,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||
snapshot.getSearch += 1
|
||||
if getSearchDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
||||
}
|
||||
guard let detail = searchDetails[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -86,6 +245,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
snapshot.updateSearchStar += 1
|
||||
guard let summary = updateSearchStarResponses[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -94,20 +261,73 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
ModelCatalogResponse(providers: [:])
|
||||
}
|
||||
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||
snapshot.getActiveRuns += 1
|
||||
return activeRunsResponse
|
||||
}
|
||||
|
||||
func runCompletionStream(
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
snapshot.runCompletionStream += 1
|
||||
lastCompletionStreamBody = body
|
||||
if completionStreamDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||
}
|
||||
if let completionStreamNetworkErrorMessage {
|
||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||
}
|
||||
if let completionStreamEvents {
|
||||
for event in completionStreamEvents {
|
||||
await onEvent(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
snapshot.attachCompletionStream += 1
|
||||
let events = completionAttachEvents[chatID] ?? []
|
||||
for event in events {
|
||||
await onEvent(event)
|
||||
}
|
||||
if completionAttachDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
|
||||
}
|
||||
}
|
||||
|
||||
func runSearchStream(
|
||||
searchID: String,
|
||||
body: SearchRunRequest,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
if searchStreamDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
|
||||
}
|
||||
if let searchStreamNetworkErrorMessage {
|
||||
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func attachSearchStream(
|
||||
searchID: String,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
snapshot.attachSearchStream += 1
|
||||
let events = searchAttachEvents[searchID] ?? []
|
||||
for event in events {
|
||||
await onEvent(event)
|
||||
}
|
||||
if searchAttachDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -182,6 +402,70 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
)
|
||||
}
|
||||
|
||||
private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message {
|
||||
Message(
|
||||
id: id,
|
||||
createdAt: date,
|
||||
role: .tool,
|
||||
content: summary,
|
||||
name: "web_search",
|
||||
metadata: .object([
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string("call-\(id)"),
|
||||
"toolName": .string("web_search"),
|
||||
"status": .string("completed"),
|
||||
"summary": .string(summary),
|
||||
"durationMs": .number(120)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||
let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A")
|
||||
let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B")
|
||||
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||
|
||||
let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant])
|
||||
|
||||
#expect(items.count == 3)
|
||||
guard case let .message(firstMessage) = items[0] else {
|
||||
Issue.record("Expected the first item to remain a normal message")
|
||||
return
|
||||
}
|
||||
#expect(firstMessage.id == "user-1")
|
||||
|
||||
guard case let .toolGroup(groupID, groupedMessages) = items[1] else {
|
||||
Issue.record("Expected adjacent tool calls to be grouped")
|
||||
return
|
||||
}
|
||||
#expect(groupID == "tool-a")
|
||||
#expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"])
|
||||
|
||||
guard case let .message(lastMessage) = items[2] else {
|
||||
Issue.record("Expected the assistant response to remain a normal message")
|
||||
return
|
||||
}
|
||||
#expect(lastMessage.id == "assistant-1")
|
||||
}
|
||||
|
||||
@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||
let tool = makeToolCallMessage(id: "tool-a", date: date)
|
||||
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||
|
||||
let items = buildTranscriptRenderItems(from: [user, tool, assistant])
|
||||
|
||||
#expect(items.count == 3)
|
||||
guard case let .message(toolMessage) = items[1] else {
|
||||
Issue.record("Expected a single tool call to use the existing inline chip")
|
||||
return
|
||||
}
|
||||
#expect(toolMessage.id == "tool-a")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -222,13 +506,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listChats == 1)
|
||||
#expect(snapshot.listSearches == 1)
|
||||
#expect(snapshot.listWorkspaceItems == 1)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 0)
|
||||
#expect(snapshot.getSearch == 0)
|
||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_050)
|
||||
let chat = makeChatSummary(id: "chat-cancelled", date: date)
|
||||
let search = makeSearchSummary(id: "search-cancelled", date: date)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search]
|
||||
)
|
||||
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
|
||||
let refreshTask = Task {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
refreshTask.cancel()
|
||||
await refreshTask.value
|
||||
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(!viewModel.isLoadingCollections)
|
||||
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
|
||||
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
@@ -242,10 +554,83 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
||||
let renamed = ChatSummary(
|
||||
id: "chat-rename",
|
||||
title: "Renamed chat",
|
||||
createdAt: date,
|
||||
updatedAt: date.addingTimeInterval(60),
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini"
|
||||
)
|
||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [original],
|
||||
updateChatTitleResponses: ["chat-rename": renamed]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [original]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
|
||||
viewModel.selectedItem = .chat("chat-rename")
|
||||
viewModel.selectedChat = detail
|
||||
|
||||
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatTitle == 1)
|
||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||
let chat = makeChatSummary(id: "chat-star", date: date)
|
||||
let search = makeSearchSummary(id: "search-star", date: date)
|
||||
var starredChat = chat
|
||||
starredChat.starred = true
|
||||
starredChat.starredAt = date.addingTimeInterval(5)
|
||||
var starredSearch = search
|
||||
starredSearch.starred = true
|
||||
starredSearch.starredAt = date.addingTimeInterval(10)
|
||||
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search],
|
||||
updateChatStarResponses: ["chat-star": starredChat],
|
||||
updateSearchStarResponses: ["search-star": starredSearch]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [chat]
|
||||
viewModel.searches = [search]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
|
||||
|
||||
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
|
||||
await viewModel.setItemStarred(.search("search-star"), starred: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatStar == 1)
|
||||
#expect(snapshot.updateSearchStar == 1)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -261,12 +646,380 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getSearch == 1)
|
||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_210)
|
||||
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
|
||||
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
|
||||
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
|
||||
await client.setGetChatDelay(50_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-old")
|
||||
viewModel.selectedChat = staleDetail
|
||||
|
||||
viewModel.select(.chat("chat-new"))
|
||||
|
||||
#expect(viewModel.displayedMessages.isEmpty)
|
||||
#expect(viewModel.isLoadingSelection)
|
||||
|
||||
try await Task.sleep(nanoseconds: 90_000_000)
|
||||
|
||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_220)
|
||||
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
|
||||
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
|
||||
await client.setGetChatDelay(20_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
|
||||
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
|
||||
|
||||
#expect(viewModel.selectedItem == .chat("chat-fast"))
|
||||
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_230)
|
||||
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
|
||||
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
|
||||
await client.setGetChatDelay(100_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
|
||||
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
|
||||
|
||||
#expect(viewModel.selectedItem == .chat("chat-slow"))
|
||||
#expect(viewModel.displayedMessages.isEmpty)
|
||||
#expect(viewModel.isLoadingSelection)
|
||||
|
||||
try await Task.sleep(nanoseconds: 150_000_000)
|
||||
|
||||
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
||||
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
|
||||
await client.setCompletionStreamNetworkError(
|
||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||
delayNanoseconds: 50_000_000
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-typing")
|
||||
viewModel.selectedChat = detail
|
||||
viewModel.composer = "continue"
|
||||
|
||||
let sendTask = Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
|
||||
#expect(viewModel.isSendingVisibleChat)
|
||||
|
||||
viewModel.startNewChat()
|
||||
|
||||
#expect(viewModel.displayedMessages.isEmpty)
|
||||
#expect(!viewModel.isSendingVisibleChat)
|
||||
|
||||
await sendTask.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_245)
|
||||
let chat = makeChatSummary(id: "chat-pin", date: date)
|
||||
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: ["chat-pin": detail]
|
||||
)
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "partial ")),
|
||||
.delta(CompletionStreamDelta(text: "response")),
|
||||
.done(CompletionStreamDone(text: "partial response"))
|
||||
])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [chat]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
|
||||
viewModel.selectedItem = .chat("chat-pin")
|
||||
viewModel.selectedChat = detail
|
||||
viewModel.composer = "continue"
|
||||
|
||||
let initialPinRequestID = viewModel.chatBottomPinRequestID
|
||||
await viewModel.sendComposer()
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.runCompletionStream == 1)
|
||||
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
||||
.done(CompletionStreamDone(text: "Reset it from Settings."))
|
||||
])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "How do I reset my password?"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
let body = await client.currentCompletionStreamBody()
|
||||
#expect(snapshot.runCompletionStream == 1)
|
||||
#expect(body?.persist == false)
|
||||
#expect(body?.chatId == nil)
|
||||
#expect(body?.provider == .openai)
|
||||
#expect(body?.messages.first?.role == .user)
|
||||
#expect(body?.messages.first?.content == "How do I reset my password?")
|
||||
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_250)
|
||||
let chat = makeChatSummary(id: "quick-chat", date: date)
|
||||
let detail = ChatDetail(
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini",
|
||||
messages: [
|
||||
Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
|
||||
Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
|
||||
]
|
||||
)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: [chat.id: detail],
|
||||
createChatResponse: chat
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
|
||||
viewModel.quickQuestionSubmittedProvider = .openai
|
||||
viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
|
||||
viewModel.quickQuestionMessages = [
|
||||
Message(
|
||||
id: "temp-assistant-quick",
|
||||
createdAt: date,
|
||||
role: .assistant,
|
||||
content: "Reset it from Settings.",
|
||||
name: nil
|
||||
)
|
||||
]
|
||||
|
||||
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
let createCall = await client.currentCreateChatCall()
|
||||
#expect(didConvert)
|
||||
#expect(snapshot.createChat == 1)
|
||||
#expect(createCall?.title == "How do I reset my password?")
|
||||
#expect(createCall?.provider == .openai)
|
||||
#expect(createCall?.model == "gpt-4.1-mini")
|
||||
#expect(createCall?.messages?.map(\.role) == [.user, .assistant])
|
||||
#expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
|
||||
#expect(viewModel.selectedItem == .chat("quick-chat"))
|
||||
#expect(viewModel.quickQuestionPrompt.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
defaults.removePersistentDomain(forName: #function)
|
||||
let settings = SybilSettingsStore(defaults: defaults)
|
||||
settings.apiBaseURL = "http://127.0.0.1:8787"
|
||||
let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
|
||||
viewModel.modelCatalog = [
|
||||
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
|
||||
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
|
||||
]
|
||||
|
||||
viewModel.setQuickQuestionProvider(.anthropic)
|
||||
viewModel.setQuickQuestionModel("claude-3-haiku")
|
||||
|
||||
#expect(viewModel.quickQuestionProvider == .anthropic)
|
||||
#expect(viewModel.quickQuestionModel == "claude-3-haiku")
|
||||
#expect(settings.preferredProvider == .openai)
|
||||
|
||||
let reloadedSettings = SybilSettingsStore(defaults: defaults)
|
||||
#expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
|
||||
#expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
|
||||
#expect(reloadedSettings.preferredProvider == .openai)
|
||||
|
||||
let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
|
||||
#expect(reloadedViewModel.quickQuestionProvider == .anthropic)
|
||||
#expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
|
||||
#expect(reloadedViewModel.provider == .openai)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||
let chat = makeChatSummary(id: "chat-active", date: date)
|
||||
let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: ["chat-active": detail],
|
||||
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||
)
|
||||
await client.setCompletionAttachEvents(
|
||||
chatID: "chat-active",
|
||||
events: [.delta(CompletionStreamDelta(text: "streaming"))],
|
||||
delayNanoseconds: 100_000_000
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
|
||||
await viewModel.reconnect()
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.getActiveRuns >= 1)
|
||||
#expect(snapshot.attachCompletionStream == 1)
|
||||
#expect(viewModel.sidebarItems.first?.isRunning == true)
|
||||
#expect(viewModel.isSendingVisibleChat)
|
||||
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||
let activeChat = makeChatSummary(id: "chat-active", date: date)
|
||||
let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [idleChat, activeChat],
|
||||
chatDetails: [
|
||||
"chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
|
||||
"chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
|
||||
],
|
||||
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.selectedItem = .chat("chat-idle")
|
||||
viewModel.composer = "new message"
|
||||
|
||||
await viewModel.reconnect()
|
||||
|
||||
#expect(viewModel.selectedItem == .chat("chat-idle"))
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
|
||||
#expect(!viewModel.isActiveSelectionSending)
|
||||
#expect(viewModel.canSendComposer)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
||||
let chat = makeChatSummary(id: "chat-3", date: date)
|
||||
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
|
||||
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: ["chat-3": refreshedDetail]
|
||||
)
|
||||
await client.setCompletionStreamNetworkError(
|
||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||
delayNanoseconds: 50_000_000
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-3")
|
||||
viewModel.selectedChat = initialDetail
|
||||
viewModel.composer = "continue"
|
||||
|
||||
let sendTask = Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
await sendTask.value
|
||||
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(viewModel.composer.isEmpty)
|
||||
#expect(!viewModel.isSending)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
|
||||
|
||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_400)
|
||||
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
|
||||
let client = MockSybilClient(
|
||||
searchDetails: ["search-3": refreshedDetail]
|
||||
)
|
||||
await client.setSearchStreamNetworkError(
|
||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||
delayNanoseconds: 50_000_000
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .search("search-3")
|
||||
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
|
||||
viewModel.composer = "refresh me"
|
||||
|
||||
let sendTask = Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
await sendTask.value
|
||||
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(viewModel.composer.isEmpty)
|
||||
#expect(!viewModel.isSending)
|
||||
|
||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.getSearch == 1)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||
}
|
||||
|
||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||
let width: CGFloat = 390
|
||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||
@@ -283,4 +1036,14 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
|
||||
#expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
|
||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
|
||||
#expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
|
||||
#expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
|
||||
#expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
|
||||
#expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
|
||||
#expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
|
||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||
}
|
||||
|
||||
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
||||
require "dotenv"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||
|
||||
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
||||
require "dotenv"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
require "yaml"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||
IOS_ROOT = File.expand_path("..", __dir__)
|
||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||
SCHEME = "Sybil"
|
||||
TARGET = "SybilApp"
|
||||
|
||||
def present?(value)
|
||||
!value.to_s.strip.empty?
|
||||
end
|
||||
|
||||
def capture(command)
|
||||
stdout, stderr, status = Open3.capture3(command)
|
||||
return stdout.strip if status.success?
|
||||
|
||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
def app_project_settings
|
||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||
end
|
||||
|
||||
def local_marketing_version
|
||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||
end
|
||||
|
||||
def local_build_number
|
||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||
end
|
||||
|
||||
def normalize_version_tag(tag)
|
||||
version = tag.to_s.strip.sub(/\Av/, "")
|
||||
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||
end
|
||||
version
|
||||
end
|
||||
|
||||
def release_version
|
||||
tag = ENV["SYBIL_VERSION_TAG"]
|
||||
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||
normalize_version_tag(tag)
|
||||
end
|
||||
|
||||
def xcode_build_setting(key, value)
|
||||
"#{key}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def app_store_connect_key_options
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
return nil unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
if present?(key_path)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_filepath: key_path
|
||||
}
|
||||
elsif present?(key_content)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_content: key_content,
|
||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||
lane :version do
|
||||
UI.message("Git tag version: #{release_version}")
|
||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||
UI.message("Checked-in build number: #{local_build_number}")
|
||||
end
|
||||
|
||||
desc "Build Sybil and upload it to TestFlight"
|
||||
lane :beta do
|
||||
version = release_version
|
||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||
api_key = nil
|
||||
|
||||
if app_store_connect_key_options
|
||||
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||
end
|
||||
|
||||
unless present?(build_number)
|
||||
build_number = (local_build_number + 1).to_s
|
||||
|
||||
if api_key
|
||||
begin
|
||||
latest = latest_testflight_build_number(
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
version: version,
|
||||
api_key: api_key,
|
||||
initial_build_number: local_build_number
|
||||
).to_i
|
||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||
rescue StandardError => e
|
||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||
|
||||
xcode_args = [
|
||||
"-allowProvisioningUpdates",
|
||||
xcode_build_setting("MARKETING_VERSION", version),
|
||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||
].join(" ")
|
||||
|
||||
ipa_path = build_app(
|
||||
project: PROJECT_FILE,
|
||||
scheme: SCHEME,
|
||||
clean: true,
|
||||
sdk: "iphoneos",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||
xcargs: xcode_args,
|
||||
export_xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
method: "app-store-connect",
|
||||
destination: "export",
|
||||
signingStyle: "automatic",
|
||||
teamID: TEAM_ID,
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
uploadSymbols: true,
|
||||
stripSwiftSymbols: true
|
||||
}
|
||||
)
|
||||
|
||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||
|
||||
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||
|
||||
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||
sh([
|
||||
"xcrun altool",
|
||||
"--upload-package #{ipa_path.shellescape}",
|
||||
"--platform ios",
|
||||
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||
"--bundle-version #{build_number.shellescape}",
|
||||
"--bundle-short-version-string #{version.shellescape}",
|
||||
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||
"--show-progress"
|
||||
].join(" "))
|
||||
end
|
||||
end
|
||||
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios version
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios version
|
||||
```
|
||||
|
||||
Show the version Fastlane will stamp into the next TestFlight archive
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Build Sybil and upload it to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
30
ios/justfile
@@ -1,10 +1,32 @@
|
||||
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
||||
simulator_name := "iPhone 16e"
|
||||
derived_data := "build/DerivedData"
|
||||
|
||||
default:
|
||||
@just build
|
||||
|
||||
build:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
fi
|
||||
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
|
||||
run: 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
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
screenshot path="build/sybil-screenshot.png":
|
||||
mkdir -p "$(dirname '{{path}}')"
|
||||
xcrun simctl io booted screenshot '{{path}}'
|
||||
|
||||
BIN
original_assets/character-busy.mp4
Normal file
BIN
original_assets/character-idle.mp4
Normal file
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
Backend API for:
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok)
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||
- Personal chat database (chats/messages + LLM call log)
|
||||
|
||||
## Stack
|
||||
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `OPENAI_API_KEY`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `XAI_API_KEY`
|
||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||
- `EXA_API_KEY`
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||
|
||||
@@ -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");
|
||||
@@ -13,6 +13,7 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
enum MessageRole {
|
||||
@@ -26,6 +27,11 @@ enum SearchSource {
|
||||
exa
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
starred
|
||||
folder
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -36,6 +42,7 @@ model User {
|
||||
|
||||
chats Chat[]
|
||||
searches Search[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
@@ -50,11 +57,15 @@ model Chat {
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -110,6 +121,7 @@ model Search {
|
||||
|
||||
title String?
|
||||
query String?
|
||||
queryNormalized String?
|
||||
|
||||
source SearchSource @default(exa)
|
||||
|
||||
@@ -128,8 +140,10 @@ model Search {
|
||||
userId String?
|
||||
|
||||
results SearchResult[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([queryNormalized, updatedAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -155,3 +169,40 @@ model SearchResult {
|
||||
|
||||
@@index([searchId, rank])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
kind ProjectKind @default(folder)
|
||||
title String
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
|
||||
items ProjectItem[]
|
||||
|
||||
@@index([kind])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ProjectItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
|
||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
chatId String?
|
||||
|
||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
||||
searchId String?
|
||||
|
||||
@@unique([projectId, chatId])
|
||||
@@unique([projectId, searchId])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
59
server/src/active-streams.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type SseStreamEvent = {
|
||||
event: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
type SseStreamListener = (event: SseStreamEvent) => void;
|
||||
|
||||
export class ActiveSseStream {
|
||||
private readonly events: SseStreamEvent[] = [];
|
||||
private readonly listeners = new Set<SseStreamListener>();
|
||||
private completed = false;
|
||||
private resolveDone!: () => void;
|
||||
|
||||
readonly done: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.done = new Promise((resolve) => {
|
||||
this.resolveDone = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
get isCompleted() {
|
||||
return this.completed;
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
if (this.completed) return;
|
||||
const entry = { event, data };
|
||||
this.events.push(entry);
|
||||
for (const listener of this.listeners) {
|
||||
listener(entry);
|
||||
}
|
||||
}
|
||||
|
||||
complete(finalEvent?: SseStreamEvent) {
|
||||
if (this.completed) return;
|
||||
if (finalEvent) {
|
||||
this.emit(finalEvent.event, finalEvent.data);
|
||||
}
|
||||
this.completed = true;
|
||||
this.listeners.clear();
|
||||
this.resolveDone();
|
||||
}
|
||||
|
||||
subscribe(listener: SseStreamListener) {
|
||||
for (const event of this.events) {
|
||||
listener(event);
|
||||
}
|
||||
|
||||
if (this.completed) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
26
server/src/browser-fetch-headers.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
|
||||
z.string().trim().url().optional()
|
||||
);
|
||||
|
||||
const DEFAULT_HERMES_AGENT_API_BASE_URL = "http://127.0.0.1:8642/v1";
|
||||
|
||||
const HermesAgentApiBaseUrlSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||
z.string().trim().url().default(DEFAULT_HERMES_AGENT_API_BASE_URL)
|
||||
);
|
||||
|
||||
const ChatWebSearchEngineSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== "string") return value;
|
||||
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
XAI_API_KEY: z.string().optional(),
|
||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
|
||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -21,6 +21,7 @@ const app = Fastify({
|
||||
|
||||
await ensureDatabaseReady(app.log);
|
||||
await warmModelCatalog(app.log);
|
||||
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
stopModelCatalogRefreshLoop();
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
|
||||
@@ -4,15 +4,14 @@ 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 { 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 +24,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 +187,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 +235,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 +258,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 +274,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 +312,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 +327,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 +338,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 +349,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 +360,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,16 +421,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 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> {
|
||||
@@ -488,10 +536,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);
|
||||
@@ -762,7 +807,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 {};
|
||||
@@ -791,7 +836,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, " ")
|
||||
@@ -807,7 +852,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",
|
||||
@@ -816,7 +861,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;
|
||||
@@ -824,65 +869,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 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",
|
||||
@@ -890,17 +889,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,
|
||||
@@ -917,16 +954,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),
|
||||
};
|
||||
@@ -937,420 +973,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* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,5 +1,9 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
fetchProviderCatalogModels,
|
||||
getProviderCatalogFallbackModels,
|
||||
listModelCatalogProviders,
|
||||
} from "./provider-adapters.js";
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
export type ProviderModelSnapshot = {
|
||||
@@ -8,29 +12,14 @@ export type ProviderModelSnapshot = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
|
||||
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||
|
||||
const providers: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const 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 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;
|
||||
@@ -48,24 +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));
|
||||
}
|
||||
|
||||
const page = await xaiClient().models.list();
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||
try {
|
||||
const models = await withTimeout(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(),
|
||||
@@ -74,35 +48,53 @@ 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: [],
|
||||
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(providers.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 {
|
||||
return {
|
||||
openai: {
|
||||
models: [...modelCatalog.openai.models],
|
||||
loadedAt: modelCatalog.openai.loadedAt,
|
||||
error: modelCatalog.openai.error,
|
||||
},
|
||||
anthropic: {
|
||||
models: [...modelCatalog.anthropic.models],
|
||||
loadedAt: modelCatalog.anthropic.loadedAt,
|
||||
error: modelCatalog.anthropic.error,
|
||||
},
|
||||
xai: {
|
||||
models: [...modelCatalog.xai.models],
|
||||
loadedAt: modelCatalog.xai.loadedAt,
|
||||
error: modelCatalog.xai.error,
|
||||
},
|
||||
const snapshot: ModelCatalogSnapshot = {};
|
||||
for (const provider of listModelCatalogProviders()) {
|
||||
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||
snapshot[provider] = {
|
||||
models: [...entry.models],
|
||||
loadedAt: entry.loadedAt,
|
||||
error: entry.error,
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, 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";
|
||||
|
||||
function asProviderEnum(p: Provider) {
|
||||
// Prisma enum values match these strings.
|
||||
return p;
|
||||
return toPrismaProvider(p);
|
||||
}
|
||||
|
||||
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
||||
@@ -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,55 +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 === "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);
|
||||
|
||||
|
||||
386
server/src/llm/protocols/chat-completions-api.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
470
server/src/llm/protocols/messages-api.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
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.";
|
||||
|
||||
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);
|
||||
if (!enabledTools.length) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
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: params.maxTokens ?? 1024,
|
||||
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);
|
||||
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: params.maxTokens ?? 1024,
|
||||
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: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
const contentByIndex = new Map<number, any>();
|
||||
const toolArgumentByIndex = new Map<number, string>();
|
||||
let roundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let roundInputTokens = 0;
|
||||
let roundOutputTokens = 0;
|
||||
let sawRoundUsage = false;
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(ev);
|
||||
|
||||
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
|
||||
const block = ev.content_block ?? {};
|
||||
if (block.type === "tool_use") {
|
||||
roundHasToolCalls = true;
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
toolArgumentByIndex.set(ev.index, "");
|
||||
} else if (block.type === "text") {
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "text",
|
||||
text: typeof block.text === "string" ? block.text : "",
|
||||
});
|
||||
} else if (block.type) {
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
|
||||
if (ev.delta?.type === "text_delta") {
|
||||
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
|
||||
if (delta) {
|
||||
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
|
||||
if (block.type === "text") {
|
||||
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
roundText += delta;
|
||||
}
|
||||
} else if (ev.delta?.type === "input_json_delta") {
|
||||
roundHasToolCalls = true;
|
||||
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
|
||||
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
|
||||
const block = contentByIndex.get(ev.index);
|
||||
if (block?.type === "tool_use") {
|
||||
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
|
||||
try {
|
||||
block.input = parseToolArgs(rawArguments);
|
||||
} catch {
|
||||
block.input = {};
|
||||
}
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "message_delta" && ev.usage) {
|
||||
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawRoundUsage) {
|
||||
usageAcc.inputTokens += roundInputTokens;
|
||||
usageAcc.outputTokens += roundOutputTokens;
|
||||
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
|
||||
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
||||
const assistantContent = indexedContent.map(([, block]) => block);
|
||||
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
|
||||
.filter(([, block]) => block?.type === "tool_use")
|
||||
.map(([index, block], callIndex) => ({
|
||||
id: block.id ?? `tool_call_${round}_${callIndex}`,
|
||||
name: block.name ?? "unknown_tool",
|
||||
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
if (roundText) {
|
||||
yield { type: "delta", text: roundText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
});
|
||||
|
||||
const toolResultBlocks: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
332
server/src/llm/protocols/responses-api.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
217
server/src/llm/provider-adapters.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
normalizeEnabledChatTools,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-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, 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" | "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[];
|
||||
};
|
||||
|
||||
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 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 isLikelyResponsesApiModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
"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 [];
|
||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
|
||||
}
|
||||
44
server/src/llm/provider-ids.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||
|
||||
const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
const prismaToApiProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
hermes_agent: "hermes-agent",
|
||||
"hermes-agent": "hermes-agent",
|
||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||
|
||||
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||
return apiToPrismaProvider[provider];
|
||||
}
|
||||
|
||||
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||
if (provider === null || provider === undefined) return null;
|
||||
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? null;
|
||||
}
|
||||
|
||||
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||
const next: Record<string, any> = { ...value };
|
||||
if ("initiatedProvider" in next) {
|
||||
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
|
||||
}
|
||||
if ("lastUsedProvider" in next) {
|
||||
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
|
||||
}
|
||||
if ("provider" in next) {
|
||||
next.provider = fromPrismaProvider(next.provider);
|
||||
}
|
||||
if (Array.isArray(next.calls)) {
|
||||
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
@@ -13,6 +13,18 @@ export function xaiClient() {
|
||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||
}
|
||||
|
||||
export function isHermesAgentConfigured() {
|
||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||
}
|
||||
|
||||
export function hermesAgentClient() {
|
||||
if (!env.HERMES_AGENT_API_KEY) throw new Error("HERMES_AGENT_API_KEY not set");
|
||||
return new OpenAI({
|
||||
apiKey: env.HERMES_AGENT_API_KEY,
|
||||
baseURL: env.HERMES_AGENT_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export function anthropicClient() {
|
||||
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
||||
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
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";
|
||||
|
||||
type StreamUsage = {
|
||||
@@ -38,7 +36,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
? await prisma.llmCall.create({
|
||||
data: {
|
||||
chatId,
|
||||
provider: req.provider as any,
|
||||
provider: toPrismaProvider(req.provider) as any,
|
||||
model: req.model,
|
||||
request: req as any,
|
||||
},
|
||||
@@ -51,14 +49,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: {
|
||||
lastUsedProvider: req.provider as any,
|
||||
lastUsedProvider: toPrismaProvider(req.provider) as any,
|
||||
lastUsedModel: req.model,
|
||||
},
|
||||
}),
|
||||
prisma.chat.updateMany({
|
||||
where: { id: chatId, initiatedProvider: null },
|
||||
data: {
|
||||
initiatedProvider: req.provider as any,
|
||||
initiatedProvider: toPrismaProvider(req.provider) as any,
|
||||
initiatedModel: req.model,
|
||||
},
|
||||
}),
|
||||
@@ -72,26 +70,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
let raw: unknown = { streamed: true };
|
||||
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai") {
|
||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
||||
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,
|
||||
},
|
||||
})
|
||||
: 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: {
|
||||
@@ -100,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;
|
||||
@@ -108,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: {
|
||||
@@ -128,45 +113,6 @@ 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);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Provider = "openai" | "anthropic" | "xai";
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
export type ChatImageAttachment = {
|
||||
kind: "image";
|
||||
@@ -34,6 +36,9 @@ export type MultiplexRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
29
server/src/search-cache.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
34
server/tests/active-streams.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ActiveSseStream, type SseStreamEvent } from "../src/active-streams.js";
|
||||
|
||||
test("ActiveSseStream replays buffered events to late subscribers", () => {
|
||||
const stream = new ActiveSseStream();
|
||||
stream.emit("delta", { text: "hel" });
|
||||
stream.emit("delta", { text: "lo" });
|
||||
|
||||
const events: SseStreamEvent[] = [];
|
||||
const unsubscribe = stream.subscribe((event) => events.push(event));
|
||||
unsubscribe();
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ event: "delta", data: { text: "hel" } },
|
||||
{ event: "delta", data: { text: "lo" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ActiveSseStream replays terminal events after completion", async () => {
|
||||
const stream = new ActiveSseStream();
|
||||
stream.emit("delta", { text: "done" });
|
||||
stream.complete({ event: "done", data: { text: "done" } });
|
||||
await stream.done;
|
||||
|
||||
const events: SseStreamEvent[] = [];
|
||||
stream.subscribe((event) => events.push(event));
|
||||
|
||||
assert.equal(stream.isCompleted, true);
|
||||
assert.deepEqual(events, [
|
||||
{ event: "delta", data: { text: "done" } },
|
||||
{ event: "done", data: { text: "done" } },
|
||||
]);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
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) {
|
||||
@@ -21,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",
|
||||
@@ -51,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" }],
|
||||
@@ -69,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: {
|
||||
@@ -88,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" }],
|
||||
@@ -105,3 +104,370 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||
});
|
||||
|
||||
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
|
||||
let requestBody: any = null;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return streamFrom([
|
||||
{ choices: [{ delta: { content: "Hi" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
]);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "hermes-agent",
|
||||
messages: [{ role: "user", content: "Say hi" }],
|
||||
enabledTools: [],
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(requestBody.model, "hermes-agent");
|
||||
assert.equal(requestBody.stream, true);
|
||||
assert.equal("tools" in requestBody, false);
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["delta", "done"]
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||
});
|
||||
|
||||
test("fetch_url sends browser-like navigation headers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "fetch_url",
|
||||
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
choices: [{ message: { content: "Fetched" } }],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
|
||||
assert.deepEqual(fetchCalls[0]?.init?.headers, {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
});
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBodies.push(body);
|
||||
if (requestBodies.length === 1) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "fetch_url",
|
||||
input: { url: "https://example.com/article" },
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Fetched" }],
|
||||
usage: { input_tokens: 5, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Fetch the article" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
|
||||
assert.equal(requestBodies.length, 2);
|
||||
assert.equal(requestBodies[0]?.model, "claude-test");
|
||||
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
|
||||
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
|
||||
assert.equal(fetchTool?.input_schema?.type, "object");
|
||||
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
|
||||
assert.equal(secondMessages.at(-1)?.role, "user");
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, false);
|
||||
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
|
||||
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
assert.equal(result.usage?.inputTokens, 8);
|
||||
assert.equal(result.usage?.outputTokens, 3);
|
||||
assert.equal(result.usage?.totalTokens, 11);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
function: {
|
||||
name: "unknown_tool",
|
||||
arguments: "{\"query\":\"current weather\"}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{ choices: [{ delta: { content: "Done" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
]);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("Messages API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestCount += 1;
|
||||
requestBodies.push(body);
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 3, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "I'll check that." },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "unknown_tool",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||
usage: { output_tokens: 2 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 4, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
assert.equal(requestBodies[0]?.stream, true);
|
||||
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
|
||||
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, true);
|
||||
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
|
||||
});
|
||||
|
||||
26
server/tests/message-content.test.ts
Normal file
@@ -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\./);
|
||||
});
|
||||
36
server/tests/provider-adapters.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
provider: "openai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
|
||||
provider: "openai",
|
||||
protocol: "responses",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
|
||||
provider: "anthropic",
|
||||
protocol: "messages",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
|
||||
provider: "xai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||
provider: "hermes-agent",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
});
|
||||
12
server/tests/provider-ids.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
|
||||
|
||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||
initiatedProvider: "hermes-agent",
|
||||
lastUsedProvider: "xai",
|
||||
});
|
||||
});
|
||||
25
server/tests/search-cache.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
|
||||
|
||||
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
||||
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
||||
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
|
||||
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` | `hermes-agent` (default: `openai`)
|
||||
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
||||
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
SearchStreamHandlers,
|
||||
SearchSummary,
|
||||
SessionStatus,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -41,6 +42,11 @@ export class SybilApiClient {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
async listWorkspaceItems() {
|
||||
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async createChat(title?: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||
method: "POST",
|
||||
@@ -54,6 +60,22 @@ export class SybilApiClient {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatTitle(chatId: string, title: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||
method: "PATCH",
|
||||
body: { title },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -84,6 +106,14 @@ export class SybilApiClient {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async deleteSearch(searchId: string) {
|
||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -94,6 +124,7 @@ export class SybilApiClient {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
335
tui/src/index.ts
@@ -11,6 +11,7 @@ import type {
|
||||
SearchDetail,
|
||||
SearchSummary,
|
||||
ToolCallEvent,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
@@ -19,6 +20,8 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -29,7 +32,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -39,11 +42,13 @@ type ToolLogMetadata = {
|
||||
resultPreview?: string | null;
|
||||
};
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
"hermes-agent": ["hermes-agent"],
|
||||
};
|
||||
|
||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
@@ -74,6 +79,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -90,33 +96,67 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...chat };
|
||||
}
|
||||
|
||||
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
|
||||
return { type: "search", ...search };
|
||||
}
|
||||
|
||||
function splitWorkspaceItems(items: WorkspaceItem[]) {
|
||||
const chats: ChatSummary[] = [];
|
||||
const searches: SearchSummary[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type === "chat") {
|
||||
const { type: _type, ...chat } = item;
|
||||
chats.push(chat);
|
||||
} else {
|
||||
const { type: _type, ...search } = item;
|
||||
searches.push(search);
|
||||
}
|
||||
}
|
||||
return { chats, searches };
|
||||
}
|
||||
|
||||
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
||||
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
|
||||
}
|
||||
|
||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
return items.map((item) => {
|
||||
if (item.type === "chat") {
|
||||
const chat = item;
|
||||
return {
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
lastUsedModel: null,
|
||||
})),
|
||||
];
|
||||
|
||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
@@ -131,13 +171,7 @@ function isToolCallLogMessage(message: Message) {
|
||||
}
|
||||
|
||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
return {
|
||||
id: `temp-tool-${event.toolCallId}`,
|
||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata: {
|
||||
const metadata: ToolLogMetadata = {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
@@ -145,12 +179,37 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
summary: event.summary,
|
||||
args: event.args,
|
||||
startedAt: event.startedAt,
|
||||
completedAt: event.completedAt,
|
||||
durationMs: event.durationMs,
|
||||
error: event.error ?? null,
|
||||
resultPreview: event.resultPreview ?? null,
|
||||
} satisfies ToolLogMetadata,
|
||||
};
|
||||
|
||||
if (event.completedAt) metadata.completedAt = event.completedAt;
|
||||
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
|
||||
|
||||
return {
|
||||
id: `temp-tool-${event.toolCallId}`,
|
||||
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
|
||||
const toolMessage = buildOptimisticToolMessage(event);
|
||||
const existingIndex = messages.findIndex(
|
||||
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
|
||||
}
|
||||
|
||||
const assistantIndex = messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||
}
|
||||
|
||||
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||
@@ -159,6 +218,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
||||
return PROVIDER_FALLBACK_MODELS[provider];
|
||||
}
|
||||
|
||||
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||
}
|
||||
|
||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
||||
if (fallback && options.includes(fallback)) return fallback;
|
||||
if (preferred && options.includes(preferred)) return preferred;
|
||||
@@ -188,6 +251,7 @@ async function main() {
|
||||
let authMode: "open" | "token" | null = null;
|
||||
let chats: ChatSummary[] = [];
|
||||
let searches: SearchSummary[] = [];
|
||||
let workspaceItems: WorkspaceItem[] = [];
|
||||
let selectedItem: SidebarSelection | null = null;
|
||||
let selectedChat: ChatDetail | null = null;
|
||||
let selectedSearch: SearchDetail | null = null;
|
||||
@@ -202,6 +266,7 @@ async function main() {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||
let errorMessage: string | null = null;
|
||||
@@ -214,6 +279,7 @@ async function main() {
|
||||
let renderedSidebarItems: SidebarItem[] = [];
|
||||
let renderedSidebarLines: string[] = [];
|
||||
let suppressedSidebarSelectEvents = 0;
|
||||
let isRenamePromptOpen = false;
|
||||
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
@@ -321,6 +387,26 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const renamePrompt = (blessed as any).prompt({
|
||||
parent: screen,
|
||||
label: " Rename chat ",
|
||||
border: "line",
|
||||
tags: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
mouse: true,
|
||||
top: "center",
|
||||
left: "center",
|
||||
width: "50%",
|
||||
height: "shrink",
|
||||
hidden: true,
|
||||
style: {
|
||||
border: { fg: "cyan" },
|
||||
label: { fg: "cyan" },
|
||||
fg: "white",
|
||||
},
|
||||
});
|
||||
|
||||
const focusables = [sidebar, transcript, composer] as const;
|
||||
|
||||
function getTranscriptViewportHeight() {
|
||||
@@ -369,7 +455,7 @@ async function main() {
|
||||
}
|
||||
|
||||
function getSidebarItems() {
|
||||
return buildSidebarItems(chats, searches);
|
||||
return buildSidebarItems(workspaceItems);
|
||||
}
|
||||
|
||||
function getSelectedChatSummary() {
|
||||
@@ -460,12 +546,13 @@ async function main() {
|
||||
? ["No chats/searches yet. Press n or /. "]
|
||||
: items.map((item) => {
|
||||
const kind = item.kind === "chat" ? "C" : "S";
|
||||
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
||||
const title = truncate(item.title, 36);
|
||||
const initiatedLabel =
|
||||
item.kind === "chat" && item.initiatedModel
|
||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||
: "";
|
||||
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
});
|
||||
|
||||
const linesChanged =
|
||||
@@ -534,7 +621,12 @@ async function main() {
|
||||
for (const message of messages) {
|
||||
const toolMeta = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolMeta) {
|
||||
const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}";
|
||||
const prefix =
|
||||
toolMeta.status === "failed"
|
||||
? "{red-fg}[tool failed]{/red-fg}"
|
||||
: toolMeta.status === "initiated"
|
||||
? "{yellow-fg}[tool running]{/yellow-fg}"
|
||||
: "{cyan-fg}[tool]{/cyan-fg}";
|
||||
const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
|
||||
parts.push(`${prefix} ${escapeTags(summary)}`);
|
||||
continue;
|
||||
@@ -640,7 +732,7 @@ async function main() {
|
||||
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||
|
||||
let controls =
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
|
||||
if (!isSearchMode) {
|
||||
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
|
||||
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||
@@ -693,6 +785,7 @@ async function main() {
|
||||
function resetWorkspaceState() {
|
||||
chats = [];
|
||||
searches = [];
|
||||
workspaceItems = [];
|
||||
selectedItem = null;
|
||||
selectedChat = null;
|
||||
selectedSearch = null;
|
||||
@@ -759,11 +852,13 @@ async function main() {
|
||||
updateUI();
|
||||
|
||||
try {
|
||||
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
|
||||
const nextWorkspaceItems = await api.listWorkspaceItems();
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
workspaceItems = nextWorkspaceItems;
|
||||
chats = nextChats;
|
||||
searches = nextSearches;
|
||||
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||
selectedItem = options.preferredSelection;
|
||||
draftKind = null;
|
||||
@@ -799,6 +894,27 @@ async function main() {
|
||||
composer.readInput();
|
||||
}
|
||||
|
||||
function shouldIgnoreGlobalShortcut() {
|
||||
return isRenamePromptOpen || isTextInputFocused(screen, composer);
|
||||
}
|
||||
|
||||
function promptForChatTitle(currentTitle: string) {
|
||||
isRenamePromptOpen = true;
|
||||
updateUI();
|
||||
return new Promise<string | null>((resolve) => {
|
||||
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
|
||||
isRenamePromptOpen = false;
|
||||
renamePrompt.hide();
|
||||
screen.render();
|
||||
if (err || value === null || value === undefined) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cycleFocus(step: 1 | -1) {
|
||||
const focused = screen.focused;
|
||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||
@@ -867,9 +983,20 @@ async function main() {
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
updateUI();
|
||||
} catch {
|
||||
@@ -912,6 +1039,7 @@ async function main() {
|
||||
chatId = chat.id;
|
||||
draftKind = null;
|
||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||
selectedItem = { kind: "chat", id: chat.id };
|
||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||
selectedChat = {
|
||||
@@ -919,6 +1047,8 @@ async function main() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -977,29 +1107,7 @@ async function main() {
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
if (!pendingChatState) return;
|
||||
const alreadyPresent = pendingChatState.messages.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
);
|
||||
if (alreadyPresent) return;
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = pendingChatState.messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
|
||||
if (assistantIndex < 0) {
|
||||
pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) };
|
||||
} else {
|
||||
pendingChatState = {
|
||||
...pendingChatState,
|
||||
messages: [
|
||||
...pendingChatState.messages.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...pendingChatState.messages.slice(assistantIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
|
||||
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
@@ -1077,6 +1185,7 @@ async function main() {
|
||||
draftKind = null;
|
||||
selectedItem = { kind: "search", id: searchId };
|
||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||
selectedChat = null;
|
||||
forceScrollToBottom = true;
|
||||
updateUI();
|
||||
@@ -1094,6 +1203,8 @@ async function main() {
|
||||
query,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -1256,9 +1367,93 @@ async function main() {
|
||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||
}
|
||||
|
||||
async function handleRenameSelection() {
|
||||
if (!selectedItem || selectedItem.kind !== "chat") return;
|
||||
|
||||
const chatId = selectedItem.id;
|
||||
const summary = chats.find((chat) => chat.id === chatId);
|
||||
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
|
||||
const value = await promptForChatTitle(currentTitle);
|
||||
const title = value?.trim();
|
||||
if (!title) {
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const updated = await api.updateChatTitle(chatId, title);
|
||||
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
|
||||
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
|
||||
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedSearch?.id === updated.id) {
|
||||
selectedSearch = {
|
||||
...selectedSearch,
|
||||
title: updated.title,
|
||||
query: updated.query,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
const currentIndex = PROVIDERS.indexOf(provider);
|
||||
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
|
||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||
const currentIndex = Math.max(0, cycleProviders.indexOf(provider));
|
||||
const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai";
|
||||
provider = nextProvider;
|
||||
syncModelForProvider();
|
||||
updateUI();
|
||||
@@ -1339,18 +1534,18 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["q"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
screen.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
screen.key(["tab"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
cycleFocus(1);
|
||||
});
|
||||
|
||||
screen.key(["S-tab", "backtab"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
cycleFocus(-1);
|
||||
});
|
||||
|
||||
@@ -1367,36 +1562,50 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["n"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
handleCreateChat();
|
||||
});
|
||||
|
||||
screen.key(["/"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
handleCreateSearch();
|
||||
});
|
||||
|
||||
screen.key(["d"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleDeleteSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["s"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleToggleStarSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["p"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleProvider();
|
||||
});
|
||||
|
||||
screen.key(["m"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleModel();
|
||||
});
|
||||
|
||||
screen.key(["r"], () => {
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleRenameSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["C-r"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await refreshCollections({ loadSelection: true });
|
||||
await refreshModels();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "openai" | "anthropic" | "xai";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
|
||||
};
|
||||
|
||||
export type ModelCatalogResponse = {
|
||||
providers: Record<Provider, ProviderModelInfo>;
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatSummary = {
|
||||
@@ -15,6 +15,8 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -27,8 +29,20 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -41,12 +55,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: "initiated" | "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -56,6 +70,8 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -85,6 +101,8 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Sybil chat and search workspace" />
|
||||
<meta name="application-name" content="Sybil" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
||||
<title>Sybil</title>
|
||||
</head>
|
||||
|
||||
BIN
web/public/StalinistOne-Regular.ttf
Normal file
BIN
web/public/character-busy.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/public/character-idle.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
web/public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
web/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
@@ -1,9 +1,32 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Sybil",
|
||||
"short_name": "Sybil",
|
||||
"description": "Sybil chat and search workspace",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a"
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"background_color": "#0b0718",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12
web/public/sw.js
Normal file
@@ -0,0 +1,12 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
1649
web/src/App.tsx
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||
return (
|
||||
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||
<div className="app-grid-surface app-safe-pad flex h-full items-center justify-center">
|
||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||
<div className="mb-6">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Globe2, Link2, Wrench } from "lucide-preact";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
type Props = {
|
||||
messages: Message[];
|
||||
@@ -14,7 +16,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -71,9 +73,40 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
||||
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
|
||||
type ToolStackStyle = JSX.CSSProperties & {
|
||||
"--tool-stack-x"?: string;
|
||||
"--tool-stack-y"?: string;
|
||||
"--tool-stack-z"?: string;
|
||||
"--tool-stack-scale"?: string;
|
||||
"--tool-stack-opacity"?: string;
|
||||
"--tool-stack-delay"?: string;
|
||||
"--tool-stack-from-transform"?: string;
|
||||
"--tool-stack-to-transform"?: string;
|
||||
"--tool-stack-from-opacity"?: string;
|
||||
"--tool-stack-to-opacity"?: string;
|
||||
};
|
||||
type ToolStackContainerStyle = JSX.CSSProperties & {
|
||||
"--tool-stack-from-height"?: string;
|
||||
"--tool-stack-to-height"?: string;
|
||||
};
|
||||
type ToolStackMotionDirection = "expand" | "collapse" | null;
|
||||
|
||||
const COLLAPSED_TOOL_STACK_LIMIT = 4;
|
||||
const TOOL_STACK_CARD_HEIGHT = 62;
|
||||
const TOOL_STACK_CARD_GAP = 10;
|
||||
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
|
||||
|
||||
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
|
||||
if (metadata.status === "failed") return "failed";
|
||||
if (metadata.status === "initiated") return "initiated";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
|
||||
return [
|
||||
isFailed ? "Failed" : "Completed",
|
||||
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||
formatDuration(metadata.durationMs),
|
||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||
]
|
||||
@@ -81,53 +114,343 @@ function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFaile
|
||||
.join(" • ");
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
function buildMessageRenderItems(messages: Message[]) {
|
||||
const items: MessageRenderItem[] = [];
|
||||
let toolRun: Message[] = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{messages.map((message) => {
|
||||
const flushToolRun = () => {
|
||||
if (!toolRun.length) return;
|
||||
if (toolRun.length === 1) {
|
||||
items.push({ kind: "message", message: toolRun[0] });
|
||||
} else {
|
||||
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
|
||||
}
|
||||
toolRun = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
|
||||
toolRun.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
items.push({ kind: "message", message });
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
return items;
|
||||
}
|
||||
|
||||
function getToolCallMessageIDs(messages: Message[]) {
|
||||
const ids = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool" && asToolLogMetadata(message.metadata)) ids.add(message.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getToolStackHeight(messageCount: number, expanded: boolean) {
|
||||
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||
return expanded
|
||||
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
|
||||
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
|
||||
}
|
||||
|
||||
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
|
||||
const collapsedHeight = getToolStackHeight(messageCount, false);
|
||||
const expandedHeight = getToolStackHeight(messageCount, true);
|
||||
const targetHeight = expanded ? expandedHeight : collapsedHeight;
|
||||
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
|
||||
|
||||
return {
|
||||
"--tool-stack-from-height": fromHeight,
|
||||
"--tool-stack-to-height": targetHeight,
|
||||
height: targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpandedToolLayout(index: number, messageCount: number) {
|
||||
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
|
||||
return {
|
||||
opacity: "1",
|
||||
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
|
||||
x: "0px",
|
||||
y,
|
||||
z: "0px",
|
||||
scale: "1",
|
||||
zIndex: messageCount - index,
|
||||
};
|
||||
}
|
||||
|
||||
function getCollapsedToolLayout(index: number, messageCount: number) {
|
||||
const depth = messageCount - index - 1;
|
||||
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
|
||||
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||
const x = `${visibleDepth * 11}px`;
|
||||
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
|
||||
const z = `${visibleDepth * -36}px`;
|
||||
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
|
||||
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
|
||||
|
||||
return {
|
||||
opacity,
|
||||
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
scale,
|
||||
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
|
||||
};
|
||||
}
|
||||
|
||||
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
|
||||
const expandedLayout = getExpandedToolLayout(index, messageCount);
|
||||
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
|
||||
const targetLayout = expanded ? expandedLayout : collapsedLayout;
|
||||
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
|
||||
|
||||
return {
|
||||
"--tool-stack-x": targetLayout.x,
|
||||
"--tool-stack-y": targetLayout.y,
|
||||
"--tool-stack-z": targetLayout.z,
|
||||
"--tool-stack-scale": targetLayout.scale,
|
||||
"--tool-stack-opacity": targetLayout.opacity,
|
||||
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
|
||||
"--tool-stack-from-transform": fromLayout.transform,
|
||||
"--tool-stack-to-transform": targetLayout.transform,
|
||||
"--tool-stack-from-opacity": fromLayout.opacity,
|
||||
"--tool-stack-to-opacity": targetLayout.opacity,
|
||||
opacity: targetLayout.opacity,
|
||||
transform: targetLayout.transform,
|
||||
zIndex: targetLayout.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function ToolCallCard({
|
||||
message,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
message: Message;
|
||||
className?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
}) {
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
if (!toolLogMetadata) return null;
|
||||
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const isFailed = toolLogMetadata.status === "failed";
|
||||
const toolState = getToolVisualState(toolLogMetadata);
|
||||
const isFailed = toolState === "failed";
|
||||
const isInitiated = toolState === "initiated";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||
? "border-rose-400/44 bg-[linear-gradient(90deg,hsl(350_64%_20%),hsl(342_58%_9%))]"
|
||||
: isInitiated
|
||||
? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]"
|
||||
: "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
|
||||
{toolSummary}
|
||||
</span>
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallStackCardSurface({
|
||||
messageID,
|
||||
animateEntry,
|
||||
isHidden,
|
||||
children,
|
||||
}: {
|
||||
messageID: string;
|
||||
animateEntry: boolean;
|
||||
isHidden: boolean;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
const [shouldAnimateEntry] = useState(() => animateEntry);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("tool-call-stack-card-surface", shouldAnimateEntry && !isHidden && "tool-call-stack-card-enter")}
|
||||
data-tool-stack-card-id={messageID}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallStack({
|
||||
groupKey,
|
||||
messages,
|
||||
expanded,
|
||||
entryMessageIDs,
|
||||
onToggle,
|
||||
}: {
|
||||
groupKey: string;
|
||||
messages: Message[];
|
||||
expanded: boolean;
|
||||
entryMessageIDs: Set<string>;
|
||||
onToggle: (groupKey: string) => void;
|
||||
}) {
|
||||
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
|
||||
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
|
||||
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
|
||||
const [motionRevision, setMotionRevision] = useState(0);
|
||||
const motionResetTimerRef = useRef<number | null>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setMotionDirection(expanded ? "collapse" : "expand");
|
||||
setMotionRevision((current) => current + 1);
|
||||
if (typeof window !== "undefined") {
|
||||
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
|
||||
motionResetTimerRef.current = window.setTimeout(() => {
|
||||
setMotionDirection(null);
|
||||
motionResetTimerRef.current = null;
|
||||
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
|
||||
}
|
||||
onToggle(groupKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
|
||||
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
|
||||
)}
|
||||
data-tool-stack-group={groupKey}
|
||||
data-expanded={expanded ? "true" : "false"}
|
||||
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const depth = messages.length - index - 1;
|
||||
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||
const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
|
||||
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
|
||||
isHidden && "pointer-events-none"
|
||||
)}
|
||||
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
|
||||
aria-hidden={isHidden ? "true" : undefined}
|
||||
>
|
||||
<ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
|
||||
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
||||
</ToolCallStackCardSurface>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!expanded && hiddenCount ? (
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
aria-expanded={expanded ? "true" : "false"}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||
const entryToolCallMessageIDs = useMemo(() => {
|
||||
const seenIDs = seenToolCallMessageIDsRef.current;
|
||||
if (!seenIDs) return new Set<string>();
|
||||
const entryIDs = new Set<string>();
|
||||
for (const id of toolCallMessageIDs) {
|
||||
if (!seenIDs.has(id)) entryIDs.add(id);
|
||||
}
|
||||
return entryIDs;
|
||||
}, [toolCallMessageIDs]);
|
||||
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolCallMessageIDs.size) return;
|
||||
const seenIDs = seenToolCallMessageIDsRef.current ?? new Set<string>();
|
||||
for (const id of toolCallMessageIDs) seenIDs.add(id);
|
||||
seenToolCallMessageIDsRef.current = seenIDs;
|
||||
}, [toolCallMessageIDs]);
|
||||
|
||||
const toggleToolGroup = (groupKey: string) => {
|
||||
setExpandedToolGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupKey)) next.delete(groupKey);
|
||||
else next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === "tool_group") {
|
||||
return (
|
||||
<ToolCallStack
|
||||
key={`tool-group-${item.key}`}
|
||||
groupKey={item.key}
|
||||
messages={item.messages}
|
||||
expanded={expandedToolGroups.has(item.key)}
|
||||
entryMessageIDs={entryToolCallMessageIDs}
|
||||
onToggle={toggleToolGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { message } = item;
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<ToolCallCard message={message} className="max-w-[85%]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
web/src/components/sybil-character.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHARACTER_IDLE_SRC = "/character-idle.gif";
|
||||
const CHARACTER_BUSY_SRC = "/character-busy.gif";
|
||||
|
||||
type SybilCharacterProps = {
|
||||
className?: string;
|
||||
isBusy?: boolean;
|
||||
};
|
||||
|
||||
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
|
||||
useEffect(() => {
|
||||
const busyImage = new Image();
|
||||
busyImage.src = CHARACTER_BUSY_SRC;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
className={cn(
|
||||
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
|
||||
className
|
||||
)}
|
||||
data-state={isBusy ? "busy" : "idle"}
|
||||
draggable={false}
|
||||
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,20 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "StalinistOne";
|
||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--background: 235 45% 4%;
|
||||
--foreground: 258 36% 96%;
|
||||
--muted: 246 30% 13%;
|
||||
@@ -32,6 +44,15 @@ html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -41,6 +62,8 @@ body {
|
||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -57,8 +80,8 @@ textarea {
|
||||
}
|
||||
|
||||
.sybil-wordmark {
|
||||
font-family: "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 900;
|
||||
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -70,6 +93,44 @@ textarea {
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.app-safe-frame {
|
||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left);
|
||||
}
|
||||
|
||||
.app-safe-pad {
|
||||
padding:
|
||||
max(1rem, var(--safe-area-top))
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(1rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(0.75rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(1.5rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(1.5rem, var(--safe-area-left));
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||
@@ -79,6 +140,148 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.tool-call-stack-card {
|
||||
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1));
|
||||
transform-origin: top left;
|
||||
opacity: var(--tool-stack-opacity, 1);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-a {
|
||||
animation: tool-call-stack-height-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-b {
|
||||
animation: tool-call-stack-height-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-layout-a {
|
||||
animation: tool-call-stack-layout-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-layout-b {
|
||||
animation: tool-call-stack-layout-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-surface {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-glass {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-enter {
|
||||
animation: tool-call-stack-drop-in 320ms cubic-bezier(0.18, 0.95, 0.28, 1) backwards;
|
||||
animation-delay: var(--tool-stack-delay, 0ms);
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle {
|
||||
border: 1px solid hsl(188 82% 70% / 0.36);
|
||||
background:
|
||||
linear-gradient(180deg, hsl(230 36% 16% / 0.96), hsl(238 48% 7% / 0.96)),
|
||||
hsl(236 48% 8%);
|
||||
color: hsl(186 92% 86%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(180 100% 88% / 0.08),
|
||||
0 8px 22px hsl(235 72% 2% / 0.42);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease,
|
||||
filter 160ms ease;
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle:hover {
|
||||
border-color: hsl(188 92% 74% / 0.62);
|
||||
color: hsl(184 100% 92%);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle:focus-visible {
|
||||
outline: 2px solid hsl(188 92% 72% / 0.9);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-height-a {
|
||||
from {
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
to {
|
||||
height: var(--tool-stack-to-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-height-b {
|
||||
from {
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
to {
|
||||
height: var(--tool-stack-to-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-layout-a {
|
||||
from {
|
||||
opacity: var(--tool-stack-from-opacity, 1);
|
||||
transform: var(--tool-stack-from-transform);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: var(--tool-stack-to-opacity, 1);
|
||||
transform: var(--tool-stack-to-transform);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-layout-b {
|
||||
from {
|
||||
opacity: var(--tool-stack-from-opacity, 1);
|
||||
transform: var(--tool-stack-from-transform);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: var(--tool-stack-to-opacity, 1);
|
||||
transform: var(--tool-stack-to-transform);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-drop-in {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
transform: translate3d(0, -0.65rem, 120px) scale(1.025) rotateX(3deg);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-call-stack-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-a,
|
||||
.tool-call-stack-shell-layout-b,
|
||||
.tool-call-stack-card-layout-a,
|
||||
.tool-call-stack-card-layout-b,
|
||||
.tool-call-stack-card-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.md-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -184,7 +387,13 @@ textarea {
|
||||
margin-top: 0.65rem;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.md-content li > ul,
|
||||
.md-content li > ol {
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
.md-content li + li {
|
||||
@@ -192,17 +401,31 @@ textarea {
|
||||
}
|
||||
|
||||
.md-content code {
|
||||
background: hsl(288 22% 23%);
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(249 40% 10% / 0.78);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.86em;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.md-content pre {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(287 28% 13%);
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(249 40% 10% / 0.82);
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||
}
|
||||
|
||||
.md-content pre code {
|
||||
display: block;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 0.88em;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.md-content a {
|
||||
|
||||
@@ -3,10 +3,14 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
};
|
||||
|
||||
export type SearchSummary = {
|
||||
@@ -15,8 +19,20 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -29,12 +45,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: "initiated" | "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -44,10 +60,14 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -73,6 +93,8 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
@@ -127,7 +149,7 @@ export type CompletionRequestMessage = {
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
export type Provider = "openai" | "anthropic" | "xai";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -136,7 +158,17 @@ export type ProviderModelInfo = {
|
||||
};
|
||||
|
||||
export type ModelCatalogResponse = {
|
||||
providers: Record<Provider, ProviderModelInfo>;
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ActiveRunsResponse = {
|
||||
chats: string[];
|
||||
searches: string[];
|
||||
};
|
||||
|
||||
type CompletionResponse = {
|
||||
@@ -159,9 +191,23 @@ type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
type CreateSearchRequest = {
|
||||
title?: string;
|
||||
query?: string;
|
||||
reuseByQuery?: boolean;
|
||||
};
|
||||
|
||||
type CreateSearchResponse = {
|
||||
search: SearchSummary;
|
||||
reused: boolean;
|
||||
cacheHit: boolean;
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||
@@ -209,6 +255,11 @@ export async function listChats() {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function listWorkspaceItems() {
|
||||
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||
}
|
||||
@@ -217,6 +268,15 @@ export async function listModels() {
|
||||
return api<ModelCatalogResponse>("/v1/models");
|
||||
}
|
||||
|
||||
export async function listChatTools() {
|
||||
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
||||
return data.tools;
|
||||
}
|
||||
|
||||
export async function getActiveRuns() {
|
||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||
}
|
||||
|
||||
export async function createChat(input?: string | CreateChatRequest) {
|
||||
const body = typeof input === "string" ? { title: input } : input ?? {};
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||
@@ -239,6 +299,25 @@ export async function updateChatTitle(chatId: string, title: string) {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatSettings(
|
||||
chatId: string,
|
||||
body: { title?: string; additionalSystemPrompt?: string | null; enabledTools?: string[] }
|
||||
) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -256,19 +335,35 @@ export async function listSearches() {
|
||||
return data.searches;
|
||||
}
|
||||
|
||||
export async function createSearch(body?: { title?: string; query?: string }) {
|
||||
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
||||
async function postSearch(body?: CreateSearchRequest) {
|
||||
return api<CreateSearchResponse>("/v1/searches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSearch(body?: CreateSearchRequest) {
|
||||
const data = await postSearch(body);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
|
||||
return postSearch({ ...body, reuseByQuery: true });
|
||||
}
|
||||
|
||||
export async function getSearch(searchId: string) {
|
||||
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||
method: "POST",
|
||||
@@ -333,6 +428,85 @@ type RunSearchStreamHandlers = {
|
||||
onError?: (payload: { message: string }) => void;
|
||||
};
|
||||
|
||||
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
|
||||
if (!response.ok) {
|
||||
const fallback = `${response.status} ${response.statusText}`;
|
||||
let message = fallback;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
// keep fallback message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response stream");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!dataLines.length) {
|
||||
eventName = "message";
|
||||
return;
|
||||
}
|
||||
|
||||
const dataText = dataLines.join("\n");
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = JSON.parse(dataText);
|
||||
} catch {
|
||||
payload = { message: dataText };
|
||||
}
|
||||
|
||||
dispatch(eventName, payload);
|
||||
|
||||
dataLines = [];
|
||||
eventName = "message";
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
|
||||
if (!line) {
|
||||
flushEvent();
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length) {
|
||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
flushEvent();
|
||||
}
|
||||
|
||||
export async function runSearchStream(
|
||||
searchId: string,
|
||||
body: SearchRunRequest,
|
||||
@@ -437,11 +611,38 @@ export async function runSearchStream(
|
||||
flushEvent();
|
||||
}
|
||||
|
||||
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||
const headers = new Headers({
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
if (authToken) {
|
||||
headers.set("Authorization", `Bearer ${authToken}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
await readSseStream(response, (eventName, payload) => {
|
||||
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||
else if (eventName === "done") handlers.onDone?.(payload);
|
||||
else if (eventName === "error") handlers.onError?.(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCompletion(body: {
|
||||
chatId: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
}) {
|
||||
return api<CompletionResponse>("/v1/chat-completions", {
|
||||
method: "POST",
|
||||
@@ -456,6 +657,9 @@ export async function runCompletionStream(
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
@@ -556,3 +760,26 @@ export async function runCompletionStream(
|
||||
}
|
||||
flushEvent();
|
||||
}
|
||||
|
||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||
const headers = new Headers({
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
if (authToken) {
|
||||
headers.set("Authorization", `Bearer ${authToken}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
await readSseStream(response, (eventName, payload) => {
|
||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||
else if (eventName === "done") handlers.onDone?.(payload);
|
||||
else if (eventName === "error") handlers.onError?.(payload);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { render } from "preact";
|
||||
import { RootRouter } from "@/root-router";
|
||||
import { registerServiceWorker } from "@/pwa";
|
||||
import "./index.css";
|
||||
|
||||
registerServiceWorker();
|
||||
|
||||
render(<RootRouter />, document.getElementById("app")!);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
|
||||
function readQueryFromUrl() {
|
||||
@@ -85,14 +85,16 @@ export default function SearchRoutePage() {
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
|
||||
if (!trimmed) {
|
||||
setSearch(null);
|
||||
setError(null);
|
||||
setIsRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
streamAbortRef.current = abortController;
|
||||
let wasInterrupted = false;
|
||||
@@ -106,6 +108,8 @@ export default function SearchRoutePage() {
|
||||
query: trimmed,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -117,10 +121,11 @@ export default function SearchRoutePage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await createSearch({
|
||||
const createdResult = await createReusableSearch({
|
||||
query: trimmed,
|
||||
title: trimmed.slice(0, 80),
|
||||
});
|
||||
const created = createdResult.search;
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
|
||||
setSearch((current) =>
|
||||
@@ -132,10 +137,19 @@ export default function SearchRoutePage() {
|
||||
query: created.query,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
}
|
||||
: current
|
||||
);
|
||||
|
||||
if (createdResult.cacheHit) {
|
||||
const cached = await getSearch(created.id);
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
setSearch(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
await runSearchStream(
|
||||
created.id,
|
||||
{
|
||||
@@ -248,7 +262,7 @@ export default function SearchRoutePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
||||
<div className="app-search-safe-pad h-full overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||
|
||||
9
web/src/pwa.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||