26 Commits

Author SHA1 Message Date
22aa652257 Fix iOS chat scroll pinning 2026-06-07 19:58:04 -07:00
8f6e8c17a5 ios: add fastlane 2026-06-05 23:19:14 -07:00
fccc8110f4 Show in-progress tool calls 2026-06-05 22:20:56 -07:00
f71b69ca8b some ui tweaks 2026-05-30 18:33:58 -07:00
dda20955bb restore settings ui 2026-05-30 18:28:31 -07:00
Agent
4a2493c421 Add per-chat settings UI in web app for additional system prompt and tool checkboxes 2026-05-30 18:09:35 -07:00
Agent
0bf0f95a67 Augment system prompt with date and user location (default SF) 2026-05-30 17:59:26 -07:00
600bc3befc search: cache results 2026-05-30 17:58:04 -07:00
5b7ed25522 Serve web manifest with manifest MIME type 2026-05-30 00:35:46 -07:00
39014eee18 Add fullscreen PWA support 2026-05-30 00:32:57 -07:00
a6c2ec664b adds ability to star chats 2026-05-28 22:47:58 -07:00
cb8ea935fa adds the ability to rename chats 2026-05-28 22:22:55 -07:00
f79e5e02c5 server: refresh model catalog daily 2026-05-20 22:09:11 -07:00
411790ee04 introduces workspace items as combined search+chat model 2026-05-17 00:28:09 -07:00
a8e765e026 web: cell colors and fonts tweak 2026-05-15 01:14:56 -07:00
29c6dce0e5 ios: show provider/model in subtitle 2026-05-09 21:19:34 -07:00
5855b7edb8 ios: keyboard dismissal behavior 2026-05-09 20:49:27 -07:00
ac6d55f617 ios: 1.9 2026-05-06 22:35:00 -07:00
1e045db7f4 ios: fix pull to refresh 2026-05-06 22:34:17 -07:00
12b3d8c5ad ios: get rid of "assistant is typing" 2026-05-06 21:56:19 -07:00
bd0200ac98 ios: quick question UI 2026-05-06 21:53:51 -07:00
0c9b4d1ed3 web: better overscroll behavior 2026-05-06 01:35:12 -07:00
30656842a7 web: nicer code blocks 2026-05-04 22:18:33 -07:00
8b580fd3e1 add hermes agent provider 2026-05-04 21:52:39 -07:00
195e157e1a ios: sidebar swipe 2026-05-04 21:07:55 -07:00
c5dbd12587 ios: unify sidebars 2026-05-04 20:19:58 -07:00
72 changed files with 5384 additions and 1106 deletions

5
dist/default.conf vendored
View File

@@ -17,6 +17,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /manifest.webmanifest {
default_type application/manifest+json;
try_files $uri =404;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -12,6 +12,9 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
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:-} EXA_API_KEY: ${EXA_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa} CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-} SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}

View File

@@ -33,11 +33,31 @@ Chat upload limits:
"providers": { "providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null } "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
} }
} }
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- `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` and `xai` chat completions.
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
## Active Runs ## Active Runs
@@ -55,6 +75,49 @@ Behavior notes:
- Clients should use this after app start or page refresh to restore per-row generating indicators. - Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts. - 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 ## Chats
### `GET /v1/chats` ### `GET /v1/chats`
@@ -65,8 +128,10 @@ Behavior notes:
```json ```json
{ {
"title": "optional title", "title": "optional title",
"provider": "optional openai|anthropic|xai", "provider": "optional openai|anthropic|xai|hermes-agent",
"model": "optional model id", "model": "optional model id",
"additionalSystemPrompt": "optional stored system prompt",
"enabledTools": ["web_search", "fetch_url"],
"messages": [ "messages": [
{ {
"role": "system|user|assistant|tool", "role": "system|user|assistant|tool",
@@ -82,13 +147,29 @@ Behavior notes:
Behavior notes: Behavior notes:
- `provider` and `model` must be supplied together when present. - `provider` and `model` must be supplied together when present.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`. - When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages. - Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `PATCH /v1/chats/:chatId` ### `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 }` - Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }` - 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` ### `POST /v1/chats/title/suggest`
- Body: - Body:
```json ```json
@@ -101,7 +182,8 @@ Behavior notes:
Behavior notes: Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged. - If the chat already has a non-empty title, 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` ### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }` - Response: `{ "deleted": true }`
@@ -152,7 +234,7 @@ Notes:
```json ```json
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"provider": "openai|anthropic|xai", "provider": "openai|anthropic|xai|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -180,6 +262,8 @@ Notes:
] ]
} }
], ],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2, "temperature": 0.2,
"maxTokens": 256 "maxTokens": 256
} }
@@ -199,6 +283,8 @@ Notes:
Behavior notes: Behavior notes:
- If `chatId` is present, server validates chat existence. - If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates. - 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 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. - 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`. - Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
@@ -206,11 +292,12 @@ Behavior notes:
- 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. - 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 `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction. - For `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 `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 `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks. - For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available 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` 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`. - `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 and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed. - `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
@@ -227,7 +314,7 @@ Behavior notes:
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical) - `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_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional) - `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes. - When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
- `anthropic` currently runs without server-managed tool calls. - `anthropic` currently runs without server-managed tool calls.
## Searches ## Searches
@@ -236,8 +323,24 @@ Behavior notes:
- Response: `{ "searches": SearchSummary[] }` - Response: `{ "searches": SearchSummary[] }`
### `POST /v1/searches` ### `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 }` - 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` ### `DELETE /v1/searches/:searchId`
- Response: `{ "deleted": true }` - Response: `{ "deleted": true }`
@@ -311,10 +414,14 @@ Behavior notes:
"title": null, "title": null,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null", "starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null" "lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
} }
``` ```
@@ -359,17 +466,21 @@ Behavior notes:
"title": null, "title": null,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null", "starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null", "lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"],
"messages": [Message] "messages": [Message]
} }
``` ```
`SearchSummary` `SearchSummary`
```json ```json
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." } { "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
``` ```
`SearchDetail` `SearchDetail`
@@ -380,6 +491,8 @@ Behavior notes:
"query": "...", "query": "...",
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"starred": false,
"starredAt": null,
"requestId": "...", "requestId": "...",
"latencyMs": 123, "latencyMs": 123,
"error": null, "error": null,

View File

@@ -21,7 +21,7 @@ Authentication:
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"persist": true, "persist": true,
"provider": "openai|anthropic|xai", "provider": "openai|anthropic|xai|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -49,6 +49,8 @@ Authentication:
] ]
} }
], ],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2, "temperature": 0.2,
"maxTokens": 256 "maxTokens": 256
} }
@@ -60,6 +62,8 @@ Notes:
- If `chatId` is provided, backend validates it exists. - If `chatId` is provided, backend validates it exists.
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata. - If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates. - For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `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`. - 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: Persisted chat streams with a `chatId` are backend-owned active runs:
@@ -87,6 +91,8 @@ Event order:
3. Zero or more `delta` 3. Zero or more `delta`
4. Exactly one terminal event: `done` or `error` 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` ### `meta`
```json ```json
@@ -111,6 +117,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
### `tool_call` ### `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 ```json
{ {
"toolCallId": "call_123", "toolCallId": "call_123",
@@ -121,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
"startedAt": "2026-03-02T10:00:00.000Z", "startedAt": "2026-03-02T10:00:00.000Z",
"completedAt": "2026-03-02T10:00:00.820Z", "completedAt": "2026-03-02T10:00:00.820Z",
"durationMs": 820, "durationMs": 820,
"error": null,
"resultPreview": "{\"ok\":true,...}" "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` ### `done`
```json ```json
@@ -152,8 +172,9 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text. - `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text. - `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items. - `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `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. - `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`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints. - `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, 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.
@@ -173,7 +194,8 @@ Backend database remains source of truth.
For persisted streams: For persisted streams:
- Client may optimistically render accumulated `delta` text. - 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: On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction. - Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.

20
ios/.env.example Normal file
View 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
View File

@@ -1,2 +1,11 @@
*.xcodeproj *.xcodeproj
.env
.env.*
!.env.example
build/
*.ipa
*.dSYM.zip
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/

View File

@@ -51,3 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini` - xAI: `grok-3-mini`
- Hermes Agent: `hermes-agent`

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

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

View File

@@ -5,6 +5,8 @@ import UIKit
@main @main
struct SybilApp: App struct SybilApp: App
{ {
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
SplitView() 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
}
}

View File

@@ -22,9 +22,10 @@ targets:
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6" TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.5 MARKETING_VERSION: "1.10"
CURRENT_PROJECT_VERSION: 6 CURRENT_PROJECT_VERSION: 11
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

3
ios/Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.227"

View File

@@ -2,10 +2,14 @@ import SwiftUI
public struct SplitView: View { public struct SplitView: View {
@State private var viewModel = SybilViewModel() @State private var viewModel = SybilViewModel()
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false @State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0 @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 @State private var columnVisibility: NavigationSplitViewVisibility = .automatic
private var keyboardActions: SybilKeyboardActions? { private var keyboardActions: SybilKeyboardActions? {
@@ -74,8 +78,28 @@ public struct SplitView: View {
.font(.sybil(.body)) .font(.sybil(.body))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions) .focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
.task { .task {
await viewModel.bootstrap() 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 .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
@@ -112,6 +136,28 @@ public struct SplitView: View {
columnVisibility = .all 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 { public struct SybilCommands: Commands {

View File

@@ -44,16 +44,26 @@ actor SybilAPIClient: SybilAPIClienting {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) 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] { func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats 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( let response = try await request(
"/v1/chats", "/v1/chats",
method: "POST", method: "POST",
body: AnyEncodable(ChatCreateBody(title: title)), body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
responseType: ChatCreateResponse.self responseType: ChatCreateResponse.self
) )
return response.chat return response.chat
@@ -64,6 +74,26 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat 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 { func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self) _ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
} }
@@ -108,6 +138,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat 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 { func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self) _ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
} }
@@ -617,13 +657,26 @@ actor SybilAPIClient: SybilAPIClienting {
struct CompletionStreamRequest: Codable, Sendable { struct CompletionStreamRequest: Codable, Sendable {
var chatId: String? var chatId: String?
var persist: Bool? = nil
var provider: Provider var provider: Provider
var model: String var model: String
var messages: [CompletionRequestMessage] var messages: [CompletionRequestMessage]
var userLocation: String? = nil
} }
private struct ChatCreateBody: Encodable { private struct ChatCreateBody: Encodable {
var title: String? 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 { private struct SearchCreateBody: Encodable {

View File

@@ -2,15 +2,24 @@ import Foundation
protocol SybilAPIClienting: Sendable { protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
func listChats() async throws -> [ChatSummary] 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 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 deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary] func listSearches() async throws -> [SearchSummary]
func createSearch(title: String?, query: String?) async throws -> SearchSummary func createSearch(title: String?, query: String?) async throws -> SearchSummary
func getSearch(searchID: String) async throws -> SearchDetail func getSearch(searchID: String) async throws -> SearchDetail
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary 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 deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse func getActiveRuns() async throws -> ActiveRunsResponse
@@ -32,3 +41,9 @@ protocol SybilAPIClienting: Sendable {
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws ) async throws
} }
extension SybilAPIClienting {
func createChat(title: String?) async throws -> ChatSummary {
try await createChat(title: title, provider: nil, model: nil, messages: nil)
}
}

View File

@@ -7,50 +7,55 @@ struct SybilChatTranscriptView: View {
var isSending: Bool var isSending: Bool
var topContentInset: CGFloat = 0 var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
private var hasPendingAssistant: Bool { private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
messages.contains { message in
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
}
ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
}
Color.clear
.frame(height: 18 + bottomContentInset)
.id(bottomAnchorID)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
scrollToBottom(with: proxy, animated: false)
}
.onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true)
}
} }
} }
var body: some View { private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
ScrollView { let action = {
LazyVStack(alignment: .leading, spacing: 26) { proxy.scrollTo(bottomAnchorID, anchor: .bottom)
if isSending && !hasPendingAssistant { }
HStack(spacing: 8) {
ProgressView() if animated {
.controlSize(.small) withAnimation(.easeOut(duration: 0.18), action)
.tint(SybilTheme.textMuted) } else {
Text("Assistant is typing…") action()
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.scaleEffect(x: 1, y: -1)
}
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
}
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
.scaleEffect(x: 1, y: -1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset)
.padding(.bottom, 18 + topContentInset)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.scaleEffect(x: 1, y: -1)
} }
} }
@@ -150,6 +155,12 @@ private struct MessageBubble: View {
} }
private struct ToolCallActivityChip: View { private struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
case failed
}
var metadata: ToolCallMetadata var metadata: ToolCallMetadata
var fallbackContent: String var fallbackContent: String
var createdAt: Date var createdAt: Date
@@ -196,11 +207,22 @@ private struct ToolCallActivityChip: View {
} }
private var isFailed: Bool { 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 { private var detailLabel: String {
var pieces: [String] = [isFailed ? "Failed" : "Completed"] var pieces: [String] = [stateLabel]
if let durationMs = metadata.durationMs, durationMs > 0 { if let durationMs = metadata.durationMs, durationMs > 0 {
pieces.append("\(durationMs) ms") pieces.append("\(durationMs) ms")
} }
@@ -212,14 +234,14 @@ private struct ToolCallActivityChip: View {
HStack(alignment: .top, spacing: 11) { HStack(alignment: .top, spacing: 11) {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 9) RoundedRectangle(cornerRadius: 9)
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13)) .fill(iconColor.opacity(0.13))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 9) RoundedRectangle(cornerRadius: 9)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) .stroke(iconColor.opacity(0.34), lineWidth: 1)
) )
Image(systemName: iconName) Image(systemName: iconName)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent) .foregroundStyle(iconColor)
} }
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
@@ -233,7 +255,7 @@ private struct ToolCallActivityChip: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(toolLabel) Text(toolLabel)
.font(.sybil(.caption2, weight: .semibold)) .font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90)) .foregroundStyle(iconColor.opacity(0.90))
.lineLimit(1) .lineLimit(1)
Text(detailLabel) Text(detailLabel)
@@ -248,12 +270,45 @@ private struct ToolCallActivityChip: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient) .fill(backgroundGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) 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) .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
}
}
} }

View File

@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai case openai
case anthropic case anthropic
case xai case xai
case hermesAgent = "hermes-agent"
public var displayName: String { public var displayName: String {
switch self { switch self {
case .openai: return "OpenAI" case .openai: return "OpenAI"
case .anthropic: return "Anthropic" case .anthropic: return "Anthropic"
case .xai: return "xAI" case .xai: return "xAI"
case .hermesAgent: return "Hermes Agent"
} }
} }
} }
@@ -152,6 +154,8 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var title: String? public var title: String?
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider? public var initiatedProvider: Provider?
public var initiatedModel: String? public var initiatedModel: String?
public var lastUsedProvider: Provider? public var lastUsedProvider: Provider?
@@ -164,6 +168,87 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var query: String? public var query: String?
public var createdAt: Date public var createdAt: Date
public var updatedAt: 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 { public struct Message: Codable, Identifiable, Hashable, Sendable {
@@ -306,6 +391,8 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var title: String? public var title: String?
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider? public var initiatedProvider: Provider?
public var initiatedModel: String? public var initiatedModel: String?
public var lastUsedProvider: Provider? public var lastUsedProvider: Provider?
@@ -344,6 +431,8 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var query: String? public var query: String?
public var createdAt: Date public var createdAt: Date
public var updatedAt: Date public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var requestId: String? public var requestId: String?
public var latencyMs: Int? public var latencyMs: Int?
public var error: String? public var error: String?
@@ -404,8 +493,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
} }
public struct CompletionStreamMeta: Codable, Sendable { public struct CompletionStreamMeta: Codable, Sendable {
public var chatId: String public var chatId: String?
public var callId: String public var callId: String?
public var provider: Provider public var provider: Provider
public var model: String public var model: String
} }
@@ -425,8 +514,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
public var summary: String public var summary: String
public var args: [String: JSONValue] public var args: [String: JSONValue]
public var startedAt: String public var startedAt: String
public var completedAt: String public var completedAt: String?
public var durationMs: Int public var durationMs: Int?
public var error: String? public var error: String?
public var resultPreview: String? public var resultPreview: String?
} }
@@ -522,6 +611,10 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary] var searches: [SearchSummary]
} }
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
struct ChatDetailResponse: Codable { struct ChatDetailResponse: Codable {
var chat: ChatDetail var chat: ChatDetail
} }

View File

@@ -22,75 +22,60 @@ enum PhoneRoute: Hashable {
struct SybilPhoneShellView: View { struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = [] @State private var route: PhoneRoute = .draftChat
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false @State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0 @State private var composerFocusRequest = 0
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth @State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
@State private var backSwipeOffset: CGFloat = 0 @State private var isSidebarOverlayPresented = false
@State private var backSwipeCompletionOffset: CGFloat = 0 @State private var sidebarSwipeOffset: CGFloat = 0
@State private var backSwipeIsActive = false @State private var sidebarSwipeIsActive = false
@State private var backSwipeIsCompleting = false @State private var sidebarSwipeIsCompleting = false
@State private var backSwipeHasLatched = 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 canRecognizeBackSwipe: Bool { private var canRecognizeSidebarSwipe: Bool {
!path.isEmpty && !backSwipeIsCompleting !isSidebarOverlayPresented && !sidebarSwipeIsCompleting
} }
private var backSwipeVisualOffset: CGFloat { private var sidebarOverlayProgress: CGFloat {
backSwipeOffset + backSwipeCompletionOffset 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 { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
ZStack(alignment: .topLeading) { phoneStack(width: proxy.size.width)
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.safeAreaInset(edge: .top, spacing: 0) {
phoneRootTopBar
}
.zIndex(0)
if let route = path.last {
SybilPhoneDestinationView(
viewModel: viewModel,
composerFocusRequest: $composerFocusRequest,
route: route,
onRequestBack: requestBack,
onRequestNewChat: startNewChatFromDestination
)
.background(SybilTheme.background)
.offset(x: backSwipeVisualOffset)
.shadow(
color: backSwipeVisualOffset > 0 ? Color.black.opacity(0.34) : Color.clear,
radius: backSwipeVisualOffset > 0 ? 18 : 0,
x: -8,
y: 0
)
.transition(.move(edge: .trailing))
.zIndex(1)
.background {
WorkspaceSwipePanInstaller(
direction: .right,
isEnabled: canRecognizeBackSwipe,
onBegan: { width in
beginBackSwipe(containerWidth: width)
},
onChanged: { translationX, width in
updateBackSwipe(with: translationX, containerWidth: width)
},
onEnded: { translationX, width, velocityX, didFinish in
finishBackSwipe(
translationX: translationX,
containerWidth: width,
velocityX: velocityX,
didFinish: didFinish
)
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { .onAppear {
updatePhoneStackWidth(proxy.size.width) updatePhoneStackWidth(proxy.size.width)
@@ -100,13 +85,8 @@ struct SybilPhoneShellView: View {
} }
} }
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: path.last) .animation(.easeOut(duration: 0.22), value: route)
.onChange(of: path) { _, nextPath in .animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
guard nextPath.isEmpty else {
return
}
resetBackSwipe(animated: false)
}
.onChange(of: scenePhase) { _, nextPhase in .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
case .background: case .background:
@@ -120,8 +100,8 @@ struct SybilPhoneShellView: View {
shouldRefreshOnForeground = false shouldRefreshOnForeground = false
Task { Task {
await viewModel.refreshAfterAppBecameActive( await viewModel.refreshAfterAppBecameActive(
refreshCollections: path.isEmpty, refreshCollections: isSidebarOverlayPresented,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
) )
} }
case .inactive: case .inactive:
@@ -133,16 +113,117 @@ struct SybilPhoneShellView: View {
} }
} }
private var phoneRootTopBar: some View { private func phoneStack(width: CGFloat) -> some View {
HStack { 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) SybilWordmark(size: 21)
Spacer() 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(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.background { .background {
SybilTheme.panelGradient SybilPhoneOverlayBlurBand(edge: .top)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
} }
} }
@@ -151,62 +232,98 @@ struct SybilPhoneShellView: View {
phoneStackWidth = max(width, 1) phoneStackWidth = max(width, 1)
} }
private func requestBack(animateNavigation: Bool = true) {
guard !path.isEmpty, !backSwipeIsCompleting else {
return
}
if animateNavigation {
Task {
await completeBackSwipe(containerWidth: phoneStackWidth, releaseVelocityX: 0)
}
} else {
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
}
}
private func startNewChatFromDestination() { private func startNewChatFromDestination() {
viewModel.startNewChat() viewModel.startNewChat()
composerFocusRequest += 1 composerFocusRequest += 1
replaceTopRoute(with: .draftChat) showRoute(.draftChat)
} }
private func replaceTopRoute(with route: PhoneRoute) { private func showRoute(_ nextRoute: PhoneRoute) {
if path.isEmpty { let update = {
route = nextRoute
}
if isSidebarOverlayPresented {
withAnimation(.easeOut(duration: 0.22)) { withAnimation(.easeOut(duration: 0.22)) {
path = [route] update()
isSidebarOverlayPresented = false
} }
} else { } else {
path[path.index(before: path.endIndex)] = route update()
} }
resetSidebarSwipe(animated: false)
} }
private func popRoute(disablesAnimations: Bool) { private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
let pop = { showRoute(nextRoute)
guard !path.isEmpty else { 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 return
} }
_ = path.removeLast()
}
if disablesAnimations { showRoute(PhoneRoute.from(selection: selection))
var transaction = Transaction() openingSelectionRequestID = nil
transaction.disablesAnimations = true clearSidebarHighlight(selection, after: .milliseconds(260))
withTransaction(transaction) {
pop()
}
} else {
withAnimation(.easeOut(duration: 0.22)) {
pop()
}
} }
} }
private func beginBackSwipe(containerWidth: CGFloat) { 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 = { let update = {
backSwipeIsActive = true phoneStackWidth = max(containerWidth, 1)
backSwipeHasLatched = false sidebarSwipeIsActive = true
sidebarSwipeHasLatched = false
} }
var transaction = Transaction() var transaction = Transaction()
@@ -214,97 +331,79 @@ struct SybilPhoneShellView: View {
withTransaction(transaction, update) withTransaction(transaction, update)
} }
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) { private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth) let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = BackSwipeMetrics.isLatched( let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: nextOffset, offset: nextOffset,
width: containerWidth, width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched isCurrentlyLatched: sidebarSwipeHasLatched
) )
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { withTransaction(transaction) {
backSwipeOffset = nextOffset phoneStackWidth = max(containerWidth, 1)
backSwipeHasLatched = nextLatched sidebarSwipeOffset = nextOffset
sidebarSwipeHasLatched = nextLatched
} }
} }
private func finishBackSwipe( private func finishSidebarSwipe(
translationX: CGFloat, translationX: CGFloat,
containerWidth: CGFloat, containerWidth: CGFloat,
velocityX: CGFloat, velocityX: CGFloat,
didFinish: Bool didFinish: Bool
) { ) {
guard backSwipeIsActive else { guard sidebarSwipeIsActive else {
resetBackSwipe(animated: false) resetSidebarSwipe(animated: false)
return return
} }
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth) let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = BackSwipeMetrics.isLatched( let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: finalOffset, offset: finalOffset,
width: containerWidth, width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched isCurrentlyLatched: sidebarSwipeHasLatched
) )
updateBackSwipe(with: translationX, containerWidth: containerWidth) updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
if didFinish && BackSwipeMetrics.shouldComplete( if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
offset: finalOffset, offset: finalOffset,
velocityX: velocityX, velocityX: velocityX,
width: containerWidth, width: containerWidth,
isLatched: finalLatched isLatched: finalLatched
) { ) {
Task { completeSidebarSwipe()
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
}
return return
} }
resetBackSwipe(animated: true, velocityX: velocityX) resetSidebarSwipe(animated: true, velocityX: velocityX)
} }
@MainActor private func completeSidebarSwipe() {
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async { guard !sidebarSwipeIsCompleting else {
guard !path.isEmpty else {
resetBackSwipe(animated: false)
return
}
guard !backSwipeIsCompleting else {
return return
} }
backSwipeIsCompleting = true sidebarSwipeIsCompleting = true
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth) withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
withAnimation(
BackSwipeMetrics.springAnimation(
currentOffset: backSwipeOffset,
targetOffset: targetOffset,
velocityX: releaseVelocityX
)
) {
backSwipeCompletionOffset = targetOffset - backSwipeOffset
} }
resetSidebarSwipe(animated: false)
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
} }
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) { private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = backSwipeOffset + backSwipeCompletionOffset let currentOffset = sidebarSwipeOffset
let reset = { let reset = {
backSwipeOffset = 0 sidebarSwipeOffset = 0
backSwipeCompletionOffset = 0 sidebarSwipeIsActive = false
backSwipeIsActive = false sidebarSwipeIsCompleting = false
backSwipeIsCompleting = false sidebarSwipeHasLatched = false
backSwipeHasLatched = false
} }
if animated { if animated {
withAnimation( withAnimation(
BackSwipeMetrics.springAnimation( SidebarOverlaySwipeMetrics.springAnimation(
currentOffset: currentOffset, currentOffset: currentOffset,
targetOffset: 0, targetOffset: 0,
velocityX: velocityX velocityX: velocityX
@@ -318,31 +417,79 @@ struct SybilPhoneShellView: View {
} }
} }
private struct SybilPhoneSidebarRoot: View { private enum SidebarOverlaySwipeMetrics {
@Bindable var viewModel: SybilViewModel static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
@Binding var path: [PhoneRoute] BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
@State private var openingSelection: SidebarSelection? }
@State private var openingRequestID: UUID?
private var highlightedSelection: SidebarSelection? { static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
if let openingSelection { BackSwipeMetrics.progress(for: offset, width: width)
return openingSelection }
}
guard let route = path.last else { static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
return nil BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
} }
switch route { static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
case let .chat(chatID): BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
return .chat(chatID) }
case let .search(searchID):
return .search(searchID) static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
case .draftChat, .draftSearch, .settings: BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
return nil }
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
}
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
(1 - min(max(progress, 0), 1)) * 18
}
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
min(max(progress, 0), 1) * 14
}
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
1 - (min(max(progress, 0), 1) * 0.22)
}
}
private struct SybilPhoneOverlayBlurBand: View {
var edge: VerticalEdge
var body: some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.opacity(0.34)
Rectangle()
.fill(
LinearGradient(
colors: gradientColors,
startPoint: edge == .top ? .top : .bottom,
endPoint: edge == .top ? .bottom : .top
)
)
} }
} }
private var gradientColors: [Color] {
[
Color.black.opacity(0.94),
SybilTheme.background.opacity(0.78),
Color.black.opacity(0)
]
}
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
var highlightedSelection: SidebarSelection?
var onSelect: (SidebarSelection) -> Void
var onRoute: (PhoneRoute) -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
@@ -357,64 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
} }
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { SybilSidebarItemList(
VStack(alignment: .leading, spacing: 8) { viewModel: viewModel,
ProgressView() isSelected: { item in
.tint(SybilTheme.primary) highlightedSelection == item.selection
Text("Loading conversations…") },
.font(.sybil(.footnote)) onSelect: { item in
.foregroundStyle(SybilTheme.textMuted) onSelect(item.selection)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) )
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.sidebarItems) { item in
Button {
open(item.selection)
} label: {
VStack(spacing: 0.0) {
SybilPhoneSidebarRow(item: item)
Divider()
}
}
.buttonStyle(
SybilPhoneSidebarRowButtonStyle(
isHighlighted: highlightedSelection == item.selection
)
)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
} }
.background(SybilTheme.panelGradient) .background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -429,22 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) { HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
clearOpeningSelection() viewModel.openSettings()
showRoute(.settings) onRoute(.settings)
} }
Spacer() Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
clearOpeningSelection()
viewModel.startNewSearch() viewModel.startNewSearch()
showRoute(.draftSearch) onRoute(.draftSearch)
} }
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
clearOpeningSelection()
viewModel.startNewChat() viewModel.startNewChat()
showRoute(.draftChat) onRoute(.draftChat)
} }
} }
.padding(.horizontal, 18) .padding(.horizontal, 18)
@@ -480,121 +576,6 @@ private struct SybilPhoneSidebarRoot: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
} }
private func clearOpeningSelection() {
openingRequestID = nil
openingSelection = nil
}
private func showRoute(_ route: PhoneRoute) {
withAnimation(.easeOut(duration: 0.22)) {
path = [route]
}
}
private func open(_ selection: SidebarSelection) {
guard openingSelection != selection else {
return
}
let requestID = UUID()
openingRequestID = requestID
openingSelection = selection
Task {
await viewModel.selectForNavigation(selection)
guard openingRequestID == requestID else {
return
}
showRoute(PhoneRoute.from(selection: selection))
openingRequestID = nil
openingSelection = nil
}
}
}
private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey {
static let defaultValue = false
}
private extension EnvironmentValues {
var sybilPhoneSidebarRowIsActive: Bool {
get { self[SybilPhoneSidebarRowIsActiveKey.self] }
set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue }
}
}
private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle {
var isHighlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed)
}
}
private struct SybilPhoneSidebarRow: View {
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
var item: SidebarItem
var body: some View {
let leadingWidth = 22.0
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: leadingWidth, height: leadingWidth)
.background(
Rectangle()
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Spacer()
.frame(width: leadingWidth)
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(18.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Rectangle()
.fill(
isHighlighted
? SybilTheme.selectedRowGradient
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
)
)
}
} }
private struct SybilPhoneDestinationView: View { private struct SybilPhoneDestinationView: View {
@@ -602,12 +583,15 @@ private struct SybilPhoneDestinationView: View {
@Binding var composerFocusRequest: Int @Binding var composerFocusRequest: Int
let route: PhoneRoute let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: () -> Void let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
var body: some View { var body: some View {
SybilWorkspaceView( SybilWorkspaceView(
viewModel: viewModel, viewModel: viewModel,
composerFocusRequest: composerFocusRequest, composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack, onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat onRequestNewChat: onRequestNewChat
) )

View File

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

View File

@@ -0,0 +1,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")."
}
}

View File

@@ -11,6 +11,12 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel" static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel" static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel" static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let 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 private let defaults: UserDefaults
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
var adminToken: String var adminToken: String
var preferredProvider: Provider var preferredProvider: Provider
var preferredModelByProvider: [Provider: String] var preferredModelByProvider: [Provider: String]
var quickQuestionPreferredProvider: Provider
var quickQuestionPreferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider self.preferredProvider = provider
self.preferredModelByProvider = [ let preferredModels: [Provider: String] = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini", .openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest", .anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini" .xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.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[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel) defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel) defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.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? { var trimmedTokenOrNil: String? {
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
raw.removeLast() raw.removeLast()
} }
guard var components = URLComponents(string: raw) else { guard let components = URLComponents(string: raw) else {
return nil return nil
} }

View File

@@ -4,13 +4,6 @@ import SwiftUI
struct SybilSidebarView: View { struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel @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 { private func isSelected(_ item: SidebarItem) -> Bool {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection viewModel.draftKind == nil && viewModel.selectedItem == item.selection
} }
@@ -57,112 +50,13 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
} }
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { SybilSidebarItemList(
VStack(alignment: .leading, spacing: 8) { viewModel: viewModel,
ProgressView() isSelected: isSelected,
.tint(SybilTheme.primary) onSelect: { item in
Text("Loading conversations…") viewModel.select(item.selection)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) )
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
viewModel.select(item.selection)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName(for: item))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title)
.font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(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.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
} }
.background(SybilTheme.panelGradient) .background(SybilTheme.panelGradient)
@@ -213,6 +107,197 @@ struct SybilSidebarView: View {
} }
} }
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 { struct SybilSidebarActivityIndicator: View {
var body: some View { var body: some View {
ProgressView() ProgressView()

View File

@@ -78,6 +78,7 @@ enum SybilTheme {
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14) 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 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 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() { @MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance() let navAppearance = UINavigationBarAppearance()
@@ -186,6 +187,17 @@ enum SybilTheme {
) )
} }
static var runningToolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
],
startPoint: .leading,
endPoint: .trailing
)
}
static var failedToolCallGradient: LinearGradient { static var failedToolCallGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [

View File

@@ -34,6 +34,8 @@ struct SidebarItem: Identifiable, Hashable {
var kind: Kind var kind: Kind
var title: String var title: String
var updatedAt: Date var updatedAt: Date
var starred: Bool
var starredAt: Date?
var initiatedLabel: String? var initiatedLabel: String?
var isRunning: Bool var isRunning: Bool
} }
@@ -95,6 +97,7 @@ final class SybilViewModel {
var chats: [ChatSummary] = [] var chats: [ChatSummary] = []
var searches: [SearchSummary] = [] var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
var selectedItem: SidebarSelection? var selectedItem: SidebarSelection?
var selectedChat: ChatDetail? var selectedChat: ChatDetail?
@@ -104,6 +107,7 @@ final class SybilViewModel {
var isLoadingCollections = false var isLoadingCollections = false
var isLoadingSelection = false var isLoadingSelection = false
var isCreatingSearchChat = false var isCreatingSearchChat = false
var chatBottomPinRequestID = 0
var errorMessage: String? var errorMessage: String?
var composer = "" var composer = ""
@@ -111,6 +115,16 @@ final class SybilViewModel {
var provider: Provider var provider: Provider
var modelCatalog: [Provider: ProviderModelInfo] = [:] var modelCatalog: [Provider: ProviderModelInfo] = [:]
var model: String var model: String
var quickQuestionPrompt = ""
var quickQuestionMessages: [Message] = []
var quickQuestionError: String?
var quickQuestionProvider: Provider
var quickQuestionModel: String
var quickQuestionSubmittedPrompt: String?
var quickQuestionSubmittedProvider: Provider?
var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false
var isConvertingQuickQuestion = false
@ObservationIgnored @ObservationIgnored
private var hasBootstrapped = false private var hasBootstrapped = false
@@ -132,6 +146,10 @@ final class SybilViewModel {
@ObservationIgnored @ObservationIgnored
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:] private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
@ObservationIgnored @ObservationIgnored
private var quickQuestionTask: Task<Void, Never>?
@ObservationIgnored
private var quickQuestionRunID: UUID?
@ObservationIgnored
private var isAppActive = true private var isAppActive = true
@ObservationIgnored @ObservationIgnored
private var appLifecycleGeneration = 0 private var appLifecycleGeneration = 0
@@ -141,7 +159,8 @@ final class SybilViewModel {
private let fallbackModels: [Provider: [String]] = [ private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"], .openai: ["gpt-4.1-mini"],
.anthropic: ["claude-3-5-sonnet-latest"], .anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"] .xai: ["grok-3-mini"],
.hermesAgent: ["hermes-agent"]
] ]
init( init(
@@ -152,14 +171,56 @@ final class SybilViewModel {
) { ) {
self.settings = settings self.settings = settings
self.clientFactory = clientFactory self.clientFactory = clientFactory
self.provider = settings.preferredProvider let initialProvider = settings.preferredProvider
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini" let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
self.provider = initialProvider
self.model = initialModel
let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
self.quickQuestionProvider = initialQuickQuestionProvider
self.quickQuestionModel = initialQuickQuestionModel
} }
var providerModelOptions: [String] { var providerModelOptions: [String] {
modelOptions(for: provider) modelOptions(for: provider)
} }
var providerOptions: [Provider] {
Provider.allCases.filter { candidate in
candidate != .hermesAgent || modelCatalog[candidate] != nil
}
}
var quickQuestionProviderModelOptions: [String] {
modelOptions(for: quickQuestionProvider)
}
var canSendQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var quickQuestionAnswerText: String {
for message in quickQuestionMessages.reversed() where message.role == .assistant {
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !content.isEmpty {
return content
}
}
return ""
}
var canConvertQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
!quickQuestionAnswerText.isEmpty &&
quickQuestionSubmittedProvider != nil &&
!(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
func modelOptions(for candidate: Provider) -> [String] { func modelOptions(for candidate: Provider) -> [String] {
let serverModels = modelCatalog[candidate]?.models ?? [] let serverModels = modelCatalog[candidate]?.models ?? []
if !serverModels.isEmpty { if !serverModels.isEmpty {
@@ -331,40 +392,44 @@ final class SybilViewModel {
} }
var sidebarItems: [SidebarItem] { var sidebarItems: [SidebarItem] {
let chatItems: [SidebarItem] = chats.map { chat in workspaceItems.map { item in
let initiatedLabel: String? switch item.type {
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { case .chat:
if let provider = chat.initiatedProvider { let initiatedLabel: String?
initiatedLabel = "\(provider.displayName)\(model)" if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = item.initiatedProvider {
initiatedLabel = "\(provider.displayName)\(model)"
} else {
initiatedLabel = model
}
} else { } else {
initiatedLabel = model initiatedLabel = nil
} }
} else {
initiatedLabel = nil return SidebarItem(
selection: .chat(item.id),
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
case .search:
return SidebarItem(
selection: .search(item.id),
kind: .search,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
)
} }
return SidebarItem(
selection: .chat(chat.id),
kind: .chat,
title: chatTitle(title: chat.title, messages: nil),
updatedAt: chat.updatedAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(chat.id)
)
} }
let searchItems: [SidebarItem] = searches.map { search in
SidebarItem(
selection: .search(search.id),
kind: .search,
title: searchTitle(title: search.title, query: search.query),
updatedAt: search.updatedAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(search.id)
)
}
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
} }
var selectedChatSummary: ChatSummary? { var selectedChatSummary: ChatSummary? {
@@ -415,6 +480,7 @@ final class SybilViewModel {
localActiveSearchIDs = [] localActiveSearchIDs = []
serverActiveChatIDs = [] serverActiveChatIDs = []
serverActiveSearchIDs = [] serverActiveSearchIDs = []
resetQuickQuestion()
draftIdentity = UUID() draftIdentity = UUID()
composerAttachments = [] composerAttachments = []
settings.persist() settings.persist()
@@ -444,6 +510,7 @@ final class SybilViewModel {
authMode = nil authMode = nil
chats = [] chats = []
searches = [] searches = []
workspaceItems = []
selectedItem = .settings selectedItem = .settings
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
@@ -487,6 +554,162 @@ final class SybilViewModel {
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)") SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
} }
func setQuickQuestionProvider(_ nextProvider: Provider) {
quickQuestionProvider = nextProvider
let options = modelOptions(for: nextProvider)
if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
quickQuestionModel = preferred
} else if let first = options.first {
quickQuestionModel = first
} else {
quickQuestionModel = ""
}
persistQuickQuestionModelSelection()
}
func setQuickQuestionModel(_ nextModel: String) {
quickQuestionModel = nextModel
persistQuickQuestionModelSelection()
}
private func persistQuickQuestionModelSelection() {
settings.quickQuestionPreferredProvider = quickQuestionProvider
let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedModel.isEmpty {
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
}
settings.persist()
}
func updateQuickQuestionPrompt(_ nextPrompt: String) {
guard nextPrompt != quickQuestionPrompt else {
return
}
if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
cancelQuickQuestion()
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
quickQuestionMessages = []
quickQuestionError = nil
}
quickQuestionPrompt = nextPrompt
}
func resetQuickQuestion() {
cancelQuickQuestion()
quickQuestionPrompt = ""
quickQuestionMessages = []
quickQuestionError = nil
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
isConvertingQuickQuestion = false
}
func cancelQuickQuestion() {
quickQuestionTask?.cancel()
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
@discardableResult
func sendQuickQuestion() -> Task<Void, Never>? {
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
return nil
}
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
quickQuestionError = "No model available for selected provider."
return nil
}
cancelQuickQuestion()
let selectedProvider = quickQuestionProvider
let task = Task { [weak self] in
guard let self else {
return
}
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
}
quickQuestionTask = task
return task
}
@discardableResult
func convertQuickQuestionToChat() async -> Bool {
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let answer = quickQuestionAnswerText
guard !question.isEmpty,
!answer.isEmpty,
let submittedProvider = quickQuestionSubmittedProvider,
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
!submittedModel.isEmpty,
!isQuickQuestionSending,
!isConvertingQuickQuestion
else {
return false
}
isConvertingQuickQuestion = true
quickQuestionError = nil
defer {
isConvertingQuickQuestion = false
}
do {
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
let chat = try await client().createChat(
title: title.isEmpty ? "Quick question" : title,
provider: submittedProvider,
model: submittedModel,
messages: [
CompletionRequestMessage(role: .user, content: question),
CompletionRequestMessage(role: .assistant, content: answer)
]
)
setProvider(submittedProvider, model: submittedModel)
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
draftKind = nil
selectedItem = .chat(chat.id)
selectedChat = ChatDetail(
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: []
)
selectedSearch = nil
composer = ""
composerAttachments = []
await refreshCollections(preferredSelection: .chat(chat.id))
resetQuickQuestion()
return true
} catch {
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
return false
}
}
func startNewChat() { func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat") SybilLog.debug(SybilLog.ui, "Starting draft chat")
resetSelectionLoading() resetSelectionLoading()
@@ -637,6 +860,57 @@ final class SybilViewModel {
} }
} }
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
}
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Enter a chat title."
return
}
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
errorMessage = nil
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
applyChatSummary(updated, moveToFront: true)
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
}
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard isAuthenticated else {
return
}
guard case .settings = selection else {
errorMessage = nil
do {
let client = try client()
switch selection {
case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
applyChatSummary(updated, moveToFront: false)
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
break
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
}
return
}
}
func refreshAfterSettingsChange() async { func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting") SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist() settings.persist()
@@ -700,6 +974,23 @@ final class SybilViewModel {
} }
} }
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
@@ -806,6 +1097,7 @@ final class SybilViewModel {
guard selectedItem == sourceSelection, draftKind == nil else { guard selectedItem == sourceSelection, draftKind == nil else {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
isCreatingSearchChat = false isCreatingSearchChat = false
return return
} }
@@ -817,6 +1109,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
selectedItem = .chat(chat.id) selectedItem = .chat(chat.id)
selectedSearch = nil selectedSearch = nil
@@ -830,23 +1123,106 @@ final class SybilViewModel {
isCreatingSearchChat = false isCreatingSearchChat = false
} }
private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
let runID = UUID()
quickQuestionRunID = runID
quickQuestionError = nil
quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model
quickQuestionMessages = [
Message(
id: "temp-assistant-quick-\(UUID().uuidString)",
createdAt: Date(),
role: .assistant,
content: "",
name: nil
)
]
isQuickQuestionSending = true
defer {
if quickQuestionRunID == runID {
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
}
let streamStatus = CompletionStreamStatus()
do {
try await client().runCompletionStream(
body: CompletionStreamRequest(
chatId: nil,
persist: false,
provider: provider,
model: model,
messages: [CompletionRequestMessage(role: .user, content: prompt)]
)
) { [weak self] event in
guard let self else { return }
await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
} catch {
guard quickQuestionRunID == runID else {
return
}
if isCancellation(error) {
return
}
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
}
}
private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
switch event {
case .meta:
break
case let .toolCall(payload):
upsertQuickQuestionToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutateQuickQuestionAssistantMessage { existing in
existing + payload.text
}
case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in
payload.text
}
case let .error(payload):
await streamStatus.setError(payload.message)
case .ignored:
break
}
}
private func loadInitialData(using client: any SybilAPIClienting) async { private func loadInitialData(using client: any SybilAPIClienting) async {
isLoadingCollections = true isLoadingCollections = true
errorMessage = nil errorMessage = nil
do { do {
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Loaded collections: \(chats.count) chats, \(searches.count) searches"
) )
do { do {
@@ -863,7 +1239,7 @@ final class SybilViewModel {
if case .settings = selectedItem { if case .settings = selectedItem {
nextSelection = .settings nextSelection = .settings
} else if let currentSelection = selectedItem, } else if let currentSelection = selectedItem,
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) { hasSelection(currentSelection, chats: chats, searches: searches) {
nextSelection = currentSelection nextSelection = currentSelection
} else { } else {
nextSelection = sidebarItems.first?.selection nextSelection = sidebarItems.first?.selection
@@ -893,6 +1269,11 @@ final class SybilViewModel {
} }
private func syncModelSelectionWithServerCatalog() { private func syncModelSelectionWithServerCatalog() {
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
provider = firstProvider
settings.preferredProvider = firstProvider
}
if !providerModelOptions.contains(model), let first = providerModelOptions.first { if !providerModelOptions.contains(model), let first = providerModelOptions.first {
model = first model = first
settings.preferredModelByProvider[provider] = first settings.preferredModelByProvider[provider] = first
@@ -902,6 +1283,22 @@ final class SybilViewModel {
model = preferred model = preferred
} }
if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
quickQuestionProvider = firstProvider
settings.quickQuestionPreferredProvider = firstProvider
}
if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
quickQuestionModel = first
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
}
if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
quickQuestionProviderModelOptions.contains(preferred)
{
quickQuestionModel = preferred
}
settings.persist() settings.persist()
} }
@@ -914,18 +1311,16 @@ final class SybilViewModel {
do { do {
let client = try client() let client = try client()
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Refreshed collections: \(chats.count) chats, \(searches.count) searches"
) )
errorMessage = nil errorMessage = nil
@@ -943,10 +1338,10 @@ final class SybilViewModel {
} }
if let preferredSelection, if let preferredSelection,
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) { hasSelection(preferredSelection, chats: chats, searches: searches) {
selectedItem = preferredSelection selectedItem = preferredSelection
} else if let existing = selectedItem, } else if let existing = selectedItem,
hasSelection(existing, chats: nextChats, searches: nextSearches) { hasSelection(existing, chats: chats, searches: searches) {
selectedItem = existing selectedItem = existing
} else { } else {
selectedItem = sidebarItems.first?.selection selectedItem = sidebarItems.first?.selection
@@ -959,7 +1354,9 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded() attachToVisibleActiveRunIfNeeded()
} }
} catch { } catch {
if shouldSuppressInactiveTransportError(error) { if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else { } else {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
@@ -1038,6 +1435,75 @@ final class SybilViewModel {
serverActiveSearchIDs = Set(activeRuns.searches) serverActiveSearchIDs = Set(activeRuns.searches)
} }
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
workspaceItems = items
chats = items.compactMap(\.chatSummary)
searches = items.compactMap(\.searchSummary)
}
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
chats.remove(at: existingIndex)
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
} else {
chats.insert(chat, at: 0)
}
upsertWorkspaceChat(chat, moveToFront: moveToFront)
if selectedChat?.id == chat.id {
selectedChat?.title = chat.title
selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt
selectedChat?.initiatedProvider = chat.initiatedProvider
selectedChat?.initiatedModel = chat.initiatedModel
selectedChat?.lastUsedProvider = chat.lastUsedProvider
selectedChat?.lastUsedModel = chat.lastUsedModel
}
}
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
searches.remove(at: existingIndex)
searches.insert(search, at: moveToFront ? 0 : existingIndex)
} else {
searches.insert(search, at: 0)
}
upsertWorkspaceSearch(search, moveToFront: moveToFront)
if selectedSearch?.id == search.id {
selectedSearch?.title = search.title
selectedSearch?.query = search.query
selectedSearch?.updatedAt = search.updatedAt
selectedSearch?.starred = search.starred
selectedSearch?.starredAt = search.starredAt
}
}
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
}
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
}
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex)
if moveToFront {
workspaceItems.insert(item, at: 0)
} else {
workspaceItems.insert(item, at: existingIndex)
}
return
}
workspaceItems.insert(item, at: 0)
}
private func attachToVisibleActiveRunIfNeeded() { private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else { guard draftKind == nil else {
return return
@@ -1234,6 +1700,10 @@ final class SybilViewModel {
isLoadingSelection = false isLoadingSelection = false
} }
private func requestChatBottomPin() {
chatBottomPinRequestID += 1
}
private func startSelectionRefreshTask() -> Task<Void, Never> { private func startSelectionRefreshTask() -> Task<Void, Never> {
isLoadingSelection = true isLoadingSelection = true
let task = Task { [weak self] in let task = Task { [weak self] in
@@ -1287,6 +1757,7 @@ final class SybilViewModel {
} }
selectedChat = chat selectedChat = chat
selectedSearch = nil selectedSearch = nil
requestChatBottomPin()
if let provider = chat.lastUsedProvider, if let provider = chat.lastUsedProvider,
let model = chat.lastUsedModel, let model = chat.lastUsedModel,
@@ -1359,6 +1830,7 @@ final class SybilViewModel {
} else { } else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages) pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
} }
requestChatBottomPin()
if chatID == nil { if chatID == nil {
let created = try await client.createChat(title: nil) let created = try await client.createChat(title: nil)
@@ -1369,6 +1841,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == created.id }) chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0) chats.insert(created, at: 0)
upsertWorkspaceChat(created)
if shouldShowCreatedChat { if shouldShowCreatedChat {
draftKind = nil draftKind = nil
@@ -1379,6 +1852,8 @@ final class SybilViewModel {
title: created.title, title: created.title,
createdAt: created.createdAt, createdAt: created.createdAt,
updatedAt: created.updatedAt, updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
initiatedProvider: created.initiatedProvider, initiatedProvider: created.initiatedProvider,
initiatedModel: created.initiatedModel, initiatedModel: created.initiatedModel,
lastUsedProvider: created.lastUsedProvider, lastUsedProvider: created.lastUsedProvider,
@@ -1403,6 +1878,7 @@ final class SybilViewModel {
if let draftPending = pendingDraftChatState { if let draftPending = pendingDraftChatState {
pendingDraftChatState = nil pendingDraftChatState = nil
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages) pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
requestChatBottomPin()
} else if pendingChatStates[chatID] == nil { } else if pendingChatStates[chatID] == nil {
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages) pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
} else { } else {
@@ -1439,17 +1915,7 @@ final class SybilViewModel {
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments) let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed) let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
await MainActor.run { await MainActor.run {
self.chats = self.chats.map { existing in self.applyChatSummary(updated, moveToFront: false)
if existing.id == updated.id {
return updated
}
return existing
}
if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title
self.selectedChat?.updatedAt = updated.updatedAt
}
} }
} catch { } catch {
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))") SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
@@ -1548,7 +2014,7 @@ final class SybilViewModel {
} }
case let .toolCall(payload): case let .toolCall(payload):
insertPendingToolCallMessage(payload, chatID: chatID) upsertPendingToolCallMessage(payload, chatID: chatID)
case let .delta(payload): case let .delta(payload):
guard !payload.text.isEmpty else { return } guard !payload.text.isEmpty else { return }
@@ -1582,6 +2048,7 @@ final class SybilViewModel {
searches.removeAll(where: { $0.id == created.id }) searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0) searches.insert(created, at: 0)
upsertWorkspaceSearch(created)
if shouldShowCreatedSearch { if shouldShowCreatedSearch {
draftKind = nil draftKind = nil
@@ -1607,6 +2074,8 @@ final class SybilViewModel {
query: query, query: query,
createdAt: currentSelectedSearch?.createdAt ?? now, createdAt: currentSelectedSearch?.createdAt ?? now,
updatedAt: now, updatedAt: now,
starred: currentSelectedSearch?.starred ?? false,
starredAt: currentSelectedSearch?.starredAt,
requestId: nil, requestId: nil,
latencyMs: nil, latencyMs: nil,
error: nil, error: nil,
@@ -1752,41 +2221,27 @@ final class SybilViewModel {
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
} }
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
guard let index else {
return
}
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
}
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
guard var pending = pendingChatStates[chatID] else { guard var pending = pendingChatStates[chatID] else {
return return
} }
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) { if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
pendingChatStates[chatID] = pending
return return
} }
let metadata: JSONValue = .object([ let message = toolCallMessage(for: payload)
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name),
"status": .string(payload.status),
"summary": .string(payload.summary),
"args": .object(payload.args),
"startedAt": .string(payload.startedAt),
"completedAt": .string(payload.completedAt),
"durationMs": .number(Double(payload.durationMs)),
"error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
])
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'."
: payload.summary
let message = Message(
id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(),
role: .tool,
content: summary,
name: payload.name,
metadata: metadata
)
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) { if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex) pending.messages.insert(message, at: assistantIndex)
@@ -1797,6 +2252,69 @@ final class SybilViewModel {
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
} }
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
return
}
let message = toolCallMessage(for: payload)
if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
quickQuestionMessages.insert(message, at: assistantIndex)
} else {
quickQuestionMessages.append(message)
}
}
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
var metadataObject: [String: JSONValue] = [
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name),
"status": .string(payload.status),
"summary": .string(payload.summary),
"args": .object(payload.args),
"startedAt": .string(payload.startedAt),
"error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
]
if let completedAt = payload.completedAt {
metadataObject["completedAt"] = .string(completedAt)
}
if let durationMs = payload.durationMs {
metadataObject["durationMs"] = .number(Double(durationMs))
}
let metadata: JSONValue = .object(metadataObject)
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'."
: payload.summary
return Message(
id: id ?? "temp-tool-\(payload.toolCallId)",
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
role: .tool,
content: summary,
name: payload.name,
metadata: metadata
)
}
private func toolCallDate(from value: String?) -> Date? {
guard let value else { return nil }
let fractionalFormatter = ISO8601DateFormatter()
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractionalFormatter.date(from: value) {
return date
}
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
private var currentChatID: String? { private var currentChatID: String? {
if draftKind == .chat { if draftKind == .chat {
return nil return nil

View File

@@ -50,7 +50,7 @@ struct SybilWorkspaceView: View {
} }
private var showsCustomWorkspaceNavigation: Bool { private var showsCustomWorkspaceNavigation: Bool {
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl == .back) usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
} }
private var transcriptScrollContextID: String { private var transcriptScrollContextID: String {
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat, isSending: viewModel.isSendingVisibleChat,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID
) )
.id(transcriptScrollContextID) .id(transcriptScrollContextID)
} }
@@ -232,13 +233,7 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
Text(viewModel.selectedTitle) customWorkspaceNavigationTitle
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
workspaceNavigationTrailingControl workspaceNavigationTrailingControl
} }
@@ -251,6 +246,32 @@ struct SybilWorkspaceView: View {
} }
} }
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder @ViewBuilder
private var workspaceNavigationLeadingControl: some View { private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl { switch navigationLeadingControl {
@@ -495,7 +516,7 @@ struct SybilWorkspaceView: View {
Divider() Divider()
ForEach(Provider.allCases, id: \.self) { candidate in ForEach(viewModel.providerOptions, id: \.self) { candidate in
Menu(candidate.displayName) { Menu(candidate.displayName) {
let models = viewModel.modelOptions(for: candidate) let models = viewModel.modelOptions(for: candidate)
if models.isEmpty { if models.isEmpty {
@@ -703,9 +724,7 @@ struct SybilWorkspaceView: View {
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode { composerFocused = false
composerFocused = false
}
#endif #endif
Task { Task {

View File

@@ -4,26 +4,48 @@ import Testing
@testable import Sybil @testable import Sybil
private struct MockClientCallSnapshot: Sendable { private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var listChats = 0 var listChats = 0
var listSearches = 0 var listSearches = 0
var createChat = 0
var getChat = 0 var getChat = 0
var updateChatTitle = 0
var updateChatStar = 0
var updateSearchStar = 0
var getSearch = 0 var getSearch = 0
var getActiveRuns = 0 var getActiveRuns = 0
var runCompletionStream = 0
var attachCompletionStream = 0 var attachCompletionStream = 0
var attachSearchStream = 0 var attachSearchStream = 0
} }
private struct ChatCreateCallSnapshot: Sendable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct UnexpectedClientCall: Error {} private struct UnexpectedClientCall: Error {}
private actor MockSybilClient: SybilAPIClienting { private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary] private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary] private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot() 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 getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
@@ -41,25 +63,55 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() updateChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse 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 { func currentSnapshot() -> MockClientCallSnapshot {
snapshot 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) { func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
completionStreamNetworkErrorMessage = message completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) { func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
@@ -95,12 +147,36 @@ private actor MockSybilClient: SybilAPIClienting {
AuthSession(authenticated: true, mode: "open") 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] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse 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 { if let createChatResponse {
return createChatResponse return createChatResponse
} }
@@ -118,6 +194,22 @@ private actor MockSybilClient: SybilAPIClienting {
return detail 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 { func deleteChat(chatID: String) async throws {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -128,6 +220,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse return searchesResponse
} }
@@ -150,6 +245,14 @@ private actor MockSybilClient: SybilAPIClienting {
throw UnexpectedClientCall() 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 { func deleteSearch(searchID: String) async throws {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -167,12 +270,20 @@ private actor MockSybilClient: SybilAPIClienting {
body: CompletionStreamRequest, body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws { ) async throws {
snapshot.runCompletionStream += 1
lastCompletionStreamBody = body
if completionStreamDelayNanoseconds > 0 { if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds) try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
} }
if let completionStreamNetworkErrorMessage { if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage) throw APIError.networkError(message: completionStreamNetworkErrorMessage)
} }
if let completionStreamEvents {
for event in completionStreamEvents {
await onEvent(event)
}
return
}
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -331,13 +442,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false) await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 1) #expect(snapshot.listWorkspaceItems == 1)
#expect(snapshot.listSearches == 1) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 0) #expect(snapshot.getChat == 0)
#expect(snapshot.getSearch == 0) #expect(snapshot.getSearch == 0)
#expect(viewModel.selectedItem == .chat("chat-1")) #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 @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -351,10 +490,83 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1) #expect(snapshot.getChat == 1)
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript") #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
#expect(viewModel.chatBottomPinRequestID == 1)
}
@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 @MainActor
@@ -370,6 +582,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getSearch == 1) #expect(snapshot.getSearch == 1)
@@ -470,6 +683,148 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await sendTask.value 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 @MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws { @Test func reconnectAttachesSelectedActiveChatStream() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_260) let date = Date(timeIntervalSince1970: 1_700_000_260)

9
ios/fastlane/Appfile Normal file
View 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
View 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
View 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).

View File

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

View File

@@ -1,7 +1,7 @@
# Sybil Server # Sybil Server
Backend API for: Backend API for:
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok) - LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
- Personal chat database (chats/messages + LLM call log) - Personal chat database (chats/messages + LLM call log)
## Stack ## Stack
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY` - `ANTHROPIC_API_KEY`
- `XAI_API_KEY` - `XAI_API_KEY`
- `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` - `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only) - `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`) - `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;

View File

@@ -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");

View File

@@ -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");

View File

@@ -13,6 +13,7 @@ enum Provider {
openai openai
anthropic anthropic
xai xai
hermes_agent @map("hermes-agent")
} }
enum MessageRole { enum MessageRole {
@@ -26,6 +27,11 @@ enum SearchSource {
exa exa
} }
enum ProjectKind {
starred
folder
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -36,6 +42,7 @@ model User {
chats Chat[] chats Chat[]
searches Search[] searches Search[]
projects Project[]
} }
model Chat { model Chat {
@@ -50,11 +57,15 @@ model Chat {
lastUsedProvider Provider? lastUsedProvider Provider?
lastUsedModel String? lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?
messages Message[] messages Message[]
calls LlmCall[] calls LlmCall[]
projectItems ProjectItem[]
@@index([userId]) @@index([userId])
} }
@@ -110,6 +121,7 @@ model Search {
title String? title String?
query String? query String?
queryNormalized String?
source SearchSource @default(exa) source SearchSource @default(exa)
@@ -128,8 +140,10 @@ model Search {
userId String? userId String?
results SearchResult[] results SearchResult[]
projectItems ProjectItem[]
@@index([updatedAt]) @@index([updatedAt])
@@index([queryNormalized, updatedAt])
@@index([userId]) @@index([userId])
} }
@@ -155,3 +169,40 @@ model SearchResult {
@@index([searchId, rank]) @@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])
}

View File

@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
z.string().trim().url().optional() 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( const ChatWebSearchEngineSchema = z.preprocess(
(value) => { (value) => {
if (typeof value !== "string") return value; if (typeof value !== "string") return value;
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(), XAI_API_KEY: z.string().optional(),
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(), EXA_API_KEY: z.string().optional(),
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now. // Chat-mode web_search tool configuration. Search mode remains Exa-only for now.

View File

@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import { env } from "./env.js"; import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.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"; import { registerRoutes } from "./routes.js";
const app = Fastify({ const app = Fastify({
@@ -21,6 +21,7 @@ const app = Fastify({
await ensureDatabaseReady(app.log); await ensureDatabaseReady(app.log);
await warmModelCatalog(app.log); await warmModelCatalog(app.log);
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, { await app.register(cors, {
origin: true, origin: true,
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
}); });
}); });
app.addHook("onClose", async () => {
stopModelCatalogRefreshLoop();
});
await registerRoutes(app); await registerRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST }); await app.listen({ port: env.PORT, host: env.HOST });

View File

@@ -9,7 +9,11 @@ import { z } from "zod";
import { env } from "../env.js"; import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js"; import {
buildOpenAIConversationMessage,
buildOpenAIResponsesInputMessage,
buildSystemPromptAugmentationMessage,
} from "./message-content.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS; const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
@@ -188,7 +192,43 @@ const CHAT_TOOLS: any[] = [
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []), ...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
]; ];
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => { function getToolName(tool: any) {
return typeof tool?.function?.name === "string" ? tool.function.name : null;
}
export function getAvailableChatTools() {
return CHAT_TOOLS.map((tool) => {
const name = getToolName(tool);
if (!name) return null;
return {
name,
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
};
}).filter((tool): tool is { name: string; description: string } => tool !== null);
}
export function normalizeEnabledChatTools(value: unknown) {
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
return new Set(normalizeEnabledChatTools(params.enabledTools));
}
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;
});
}
function toResponsesChatTools(tools: any[]) {
return tools.map((tool) => {
if (tool?.type !== "function") return tool; if (tool?.type !== "function") return tool;
return { return {
type: "function", type: "function",
@@ -197,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
parameters: tool.function.parameters, parameters: tool.function.parameters,
strict: false, strict: false,
}; };
}); });
}
export const CHAT_TOOL_SYSTEM_PROMPT = export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " + "You can use tools to gather up-to-date web information when needed. " +
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
client: OpenAI; client: OpenAI;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>; onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
@@ -249,15 +292,17 @@ type ToolAwareCompletionParams = {
}; };
}; };
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
export type ToolExecutionEvent = { export type ToolExecutionEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: ToolExecutionStatus;
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };
@@ -285,10 +330,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)}` : ""; const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
if (name === "web_search") { if (name === "web_search") {
const query = typeof args.query === "string" ? args.query.trim() : ""; 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") { if (status === "completed") {
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search."; return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
} }
@@ -297,6 +345,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "fetch_url") { if (name === "fetch_url") {
const url = typeof args.url === "string" ? args.url.trim() : ""; const url = typeof args.url === "string" ? args.url.trim() : "";
if (status === "initiated") {
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
}
if (status === "completed") { if (status === "completed") {
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL."; return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
} }
@@ -305,6 +356,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "codex_exec") { if (name === "codex_exec") {
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; 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") { if (status === "completed") {
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task."; return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
} }
@@ -313,6 +367,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "shell_exec") { if (name === "shell_exec") {
const command = typeof args.command === "string" ? args.command.trim() : ""; 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") { if (status === "completed") {
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command."; return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
} }
@@ -321,6 +378,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
: `Devbox shell command failed.${errSuffix}`; : `Devbox shell command failed.${errSuffix}`;
} }
if (status === "initiated") {
return `Running tool '${name}'.`;
}
if (status === "completed") { if (status === "completed") {
return `Ran tool '${name}'.`; return `Ran tool '${name}'.`;
} }
@@ -379,16 +439,38 @@ function extractHtmlTitle(html: string) {
); );
} }
function normalizeIncomingMessages(messages: ChatMessage[]) { function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message)); const enabled = getEnabledToolSet(params);
return (
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; "You can use tools to gather up-to-date web information when needed. " +
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
(enabled.has("codex_exec")
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
: "") +
(enabled.has("shell_exec")
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
: "") +
"Do not fabricate tool outputs; reason only from provided tool results."
);
} }
function normalizeIncomingResponsesInput(messages: ChatMessage[]) { function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
}
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message)); const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> { async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
@@ -853,6 +935,20 @@ function extractResponsesText(response: any, fallback = "") {
return parts.join("") || fallback; return parts.join("") || fallback;
} }
function extractChatCompletionContent(message: any) {
if (typeof message?.content === "string") return message.content;
if (!Array.isArray(message?.content)) return "";
return message.content
.map((part: any) => {
if (typeof part === "string") return part;
if (typeof part?.text === "string") return part.text;
if (typeof part?.content === "string") return part.content;
return "";
})
.join("");
}
function getUnstreamedText(finalText: string, streamedText: string) { function getUnstreamedText(finalText: string, streamedText: string) {
if (!finalText) return ""; if (!finalText) return "";
if (!streamedText) return finalText; if (!streamedText) return finalText;
@@ -890,17 +986,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
})); }));
} }
async function executeToolCallAndBuildEvent( type PreparedToolCallExecution = {
call: NormalizedToolCall, startedAtMs: number;
params: ToolAwareCompletionParams startedAt: string;
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> { parsedArgs: Record<string, unknown>;
eventArgs: Record<string, unknown>;
parseError?: unknown;
};
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
const startedAtMs = Date.now(); const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString(); const startedAt = new Date(startedAtMs).toISOString();
let toolResult: ToolRunOutcome;
let parsedArgs: Record<string, unknown> = {}; let parsedArgs: Record<string, unknown> = {};
let parseError: unknown;
try { try {
parsedArgs = toRecord(parseToolArgs(call.arguments)); 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,
},
};
}
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) { } catch (err: any) {
toolResult = { toolResult = {
ok: false, ok: false,
@@ -917,16 +1051,15 @@ async function executeToolCallAndBuildEvent(
: undefined; : undefined;
const completedAtMs = Date.now(); const completedAtMs = Date.now();
const eventArgs = buildEventArgs(call.name, parsedArgs);
const event: ToolExecutionEvent = { const event: ToolExecutionEvent = {
toolCallId: call.id, toolCallId: call.id,
name: call.name, name: call.name,
status, status,
summary: buildToolSummary(call.name, eventArgs, status, error), summary: buildToolSummary(call.name, execution.eventArgs, status, error),
args: eventArgs, args: execution.eventArgs,
startedAt, startedAt: execution.startedAt,
completedAt: new Date(completedAtMs).toISOString(), completedAt: new Date(completedAtMs).toISOString(),
durationMs: completedAtMs - startedAtMs, durationMs: completedAtMs - execution.startedAtMs,
error, error,
resultPreview: buildResultPreview(toolResult), resultPreview: buildResultPreview(toolResult),
}; };
@@ -939,7 +1072,8 @@ async function executeToolCallAndBuildEvent(
} }
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -953,7 +1087,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -988,7 +1122,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input.push(...outputItems); input.push(...outputItems);
for (const call of normalizedToolCalls) { for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); const { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event); toolEvents.push(event);
input.push({ input.push({
@@ -1008,7 +1143,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
} }
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1022,7 +1158,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
} as any); } as any);
rawResponses.push(completion); rawResponses.push(completion);
@@ -1074,7 +1210,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
conversation.push(assistantToolCallMessage); conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) { for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); const { execution } = prepareToolCallExecution(call);
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event); toolEvents.push(event);
conversation.push({ conversation.push({
@@ -1093,10 +1230,31 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
}; };
} }
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature,
max_tokens: params.maxTokens,
} as any);
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const sawUsage = mergeUsage(usageAcc, completion?.usage);
const message = completion?.choices?.[0]?.message;
return {
text: extractChatCompletionContent(message),
usage: sawUsage ? usageAcc : undefined,
raw: { response: completion, api: "chat.completions" },
toolEvents: [],
};
}
export async function* runToolAwareOpenAIChatStream( export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1110,7 +1268,7 @@ export async function* runToolAwareOpenAIChatStream(
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1197,7 +1355,9 @@ export async function* runToolAwareOpenAIChatStream(
input.push(...responseOutputItems); input.push(...responseOutputItems);
for (const call of normalizedToolCalls) { for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event); toolEvents.push(event);
yield { type: "tool_call", event }; yield { type: "tool_call", event };
input.push({ input.push({
@@ -1222,7 +1382,8 @@ export async function* runToolAwareOpenAIChatStream(
export async function* runToolAwareChatCompletionsStream( export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1236,7 +1397,7 @@ export async function* runToolAwareChatCompletionsStream(
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
@@ -1333,7 +1494,9 @@ export async function* runToolAwareChatCompletionsStream(
conversation.push(assistantToolCallMessage); conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) { for (const call of normalizedToolCalls) {
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
yield { type: "tool_call", event: initiatedEvent };
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
toolEvents.push(event); toolEvents.push(event);
yield { type: "tool_call", event }; yield { type: "tool_call", event };
conversation.push({ conversation.push({
@@ -1354,3 +1517,41 @@ export async function* runToolAwareChatCompletionsStream(
}, },
}; };
} }
export async function* runPlainChatCompletionsStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let text = "";
const stream = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages, 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: [],
},
};
}

View File

@@ -1,5 +1,19 @@
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js"; 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) { function escapeAttribute(value: string) {
return value.replace(/"/g, "&quot;"); return value.replace(/"/g, "&quot;");
} }
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
}; };
} }
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
return {
role: "system",
content: buildSystemPromptAugmentation(userLocation),
};
}
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT = 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."; "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[]) { export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content] return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
} }

View File

@@ -1,5 +1,6 @@
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { env } from "../env.js";
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
import type { Provider } from "./types.js"; import type { Provider } from "./types.js";
export type ProviderModelSnapshot = { export type ProviderModelSnapshot = {
@@ -8,10 +9,11 @@ export type ProviderModelSnapshot = {
error: string | null; error: string | null;
}; };
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>; export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
const providers: Provider[] = ["openai", "anthropic", "xai"]; const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000; const MODEL_FETCH_TIMEOUT_MS = 15000;
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const modelCatalog: ModelCatalogSnapshot = { const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null }, openai: { models: [], loadedAt: null, error: null },
@@ -19,6 +21,12 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null },
}; };
let catalogRefreshPromise: Promise<void> | null = null;
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
function uniqSorted(models: string[]) { function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
} }
@@ -59,8 +67,15 @@ async function fetchProviderModels(provider: Provider) {
return uniqSorted(page.data.map((model) => model.id)); return uniqSorted(page.data.map((model) => model.id));
} }
const page = await xaiClient().models.list(); if (provider === "xai") {
return uniqSorted(page.data.map((model) => model.id)); const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
const page = await hermesAgentClient().models.list();
const models = page.data.map((model) => model.id);
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
return uniqSorted(models);
} }
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) { async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
@@ -74,35 +89,53 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
logger?.info({ provider, modelCount: models.length }, "model catalog loaded"); logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? String(err); const message = err?.message ?? String(err);
const previous = modelCatalog[provider];
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
modelCatalog[provider] = { modelCatalog[provider] = {
models: [], models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: new Date().toISOString(), loadedAt: previous?.loadedAt ?? null,
error: message, error: message,
}; };
logger?.warn({ provider, err: message }, "failed to load provider model catalog"); logger?.warn({ provider, err: message }, "failed to load provider model catalog");
} }
} }
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) { 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 { export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
return { const snapshot: ModelCatalogSnapshot = {};
openai: { for (const provider of getCatalogProviders()) {
models: [...modelCatalog.openai.models], const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
loadedAt: modelCatalog.openai.loadedAt, snapshot[provider] = {
error: modelCatalog.openai.error, models: [...entry.models],
}, loadedAt: entry.loadedAt,
anthropic: { error: entry.error,
models: [...modelCatalog.anthropic.models], };
loadedAt: modelCatalog.anthropic.loadedAt, }
error: modelCatalog.anthropic.error, return snapshot;
},
xai: {
models: [...modelCatalog.xai.models],
loadedAt: modelCatalog.xai.loadedAt,
error: modelCatalog.xai.error,
},
};
} }

View File

@@ -1,13 +1,13 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js"; import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js"; import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) { function asProviderEnum(p: Provider) {
// Prisma enum values match these strings. return toPrismaProvider(p);
return p;
} }
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> { export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let usage: MultiplexResponse["usage"] | undefined; let usage: MultiplexResponse["usage"] | undefined;
let raw: unknown; let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = []; let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
if (req.provider === "openai") { if (req.provider === "openai" && enabledTools.length > 0) {
const client = openaiClient(); const client = openaiClient();
const r = await runToolAwareOpenAIChat({ const r = await runToolAwareOpenAIChat({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "xai") { } else if (req.provider === "xai" && enabledTools.length > 0) {
const client = xaiClient(); const client = xaiClient();
const r = await runToolAwareChatCompletions({ const r = await runToolAwareChatCompletions({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -84,10 +89,28 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const r = await runPlainChatCompletions({
client,
model: req.model,
messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
});
raw = r.raw;
outText = r.text;
usage = r.usage;
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const r = await client.messages.create({ const r = await client.messages.create({

View File

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

View File

@@ -13,6 +13,18 @@ export function xaiClient() {
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" }); 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() { export function anthropicClient() {
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set"); if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });

View File

@@ -1,13 +1,16 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { import {
buildToolLogMessageData, buildToolLogMessageData,
normalizeEnabledChatTools,
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
type ToolExecutionEvent, type ToolExecutionEvent,
} from "./chat-tools.js"; } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, Provider } from "./types.js"; import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = { type StreamUsage = {
@@ -38,7 +41,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
? await prisma.llmCall.create({ ? await prisma.llmCall.create({
data: { data: {
chatId, chatId,
provider: req.provider as any, provider: toPrismaProvider(req.provider) as any,
model: req.model, model: req.model,
request: req as any, request: req as any,
}, },
@@ -51,14 +54,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
prisma.chat.update({ prisma.chat.update({
where: { id: chatId }, where: { id: chatId },
data: { data: {
lastUsedProvider: req.provider as any, lastUsedProvider: toPrismaProvider(req.provider) as any,
lastUsedModel: req.model, lastUsedModel: req.model,
}, },
}), }),
prisma.chat.updateMany({ prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null }, where: { id: chatId, initiatedProvider: null },
data: { data: {
initiatedProvider: req.provider as any, initiatedProvider: toPrismaProvider(req.provider) as any,
initiatedModel: req.model, initiatedModel: req.model,
}, },
}), }),
@@ -72,14 +75,17 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
let raw: unknown = { streamed: true }; let raw: unknown = { streamed: true };
try { try {
if (req.provider === "openai" || req.provider === "xai") { if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : xaiClient(); const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
const streamEvents = const streamEvents =
req.provider === "openai" req.provider === "openai" && enabledTools.length > 0
? runToolAwareOpenAIChatStream({ ? runToolAwareOpenAIChatStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -88,10 +94,26 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId: chatId ?? undefined, chatId: chatId ?? undefined,
}, },
}) })
: req.provider === "hermes-agent" || enabledTools.length === 0
? runPlainChatCompletionsStream({
client,
model: req.model,
messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId: chatId ?? undefined,
},
})
: runToolAwareChatCompletionsStream({ : runToolAwareChatCompletionsStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -108,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} }
if (ev.type === "tool_call") { if (ev.type === "tool_call") {
if (shouldPersist && chatId) { if (ev.event.status !== "initiated" && shouldPersist && chatId) {
const toolMessage = buildToolLogMessageData(chatId, ev.event); const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({ await prisma.message.create({
data: { data: {
@@ -131,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const stream = await client.messages.create({ const stream = await client.messages.create({

View File

@@ -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 = { export type ChatImageAttachment = {
kind: "image"; kind: "image";
@@ -34,6 +36,9 @@ export type MultiplexRequest = {
provider: Provider; provider: Provider;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
}; };

View File

@@ -8,11 +8,18 @@ import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js"; import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js"; import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js"; import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js"; import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js"; import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import type { ChatAttachment } from "./llm/types.js"; import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
type IncomingChatMessage = { type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool"; role: "system" | "user" | "assistant" | "tool";
content: string; content: string;
@@ -44,6 +51,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
return message.role === "tool" && isToolCallLogMetadata(message.metadata); return message.role === "tool" && isToolCallLogMetadata(message.metadata);
} }
function getHeaderString(req: FastifyRequest, name: string) {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value.find((item) => item.trim());
return typeof value === "string" && value.trim() ? value : undefined;
}
function decodeHeaderPart(value: string | undefined) {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
function inferRequestUserLocation(req: FastifyRequest) {
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
if (explicit) return explicit;
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
if (vercelLocation) return vercelLocation;
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
}
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) { async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant"); const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return; if (!incoming.length) return;
@@ -125,9 +169,12 @@ const CompletionStreamBody = z
.object({ .object({
chatId: z.string().optional(), chatId: z.string().optional(),
persist: z.boolean().optional(), persist: z.boolean().optional(),
provider: z.enum(["openai", "anthropic", "xai"]), provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}) })
@@ -152,6 +199,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
}; };
} }
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
}
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
return {
...body,
additionalSystemPrompt,
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
};
}
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
body: T
) {
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
return prependAdditionalSystemPrompt(body);
}
const chat = await prisma.chat.findUnique({
where: { id: body.chatId },
select: { additionalSystemPrompt: true, enabledTools: true },
});
if (!chat) return prependAdditionalSystemPrompt(body);
return prependAdditionalSystemPrompt({
...body,
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
});
}
const SearchRunBody = z.object({ const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(), query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(), title: z.string().trim().min(1).optional(),
@@ -318,11 +400,153 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>(); const activeChatStreams = new Map<string, ActiveSseStream>();
const activeSearchStreams = new Map<string, ActiveSseStream>(); const activeSearchStreams = new Map<string, ActiveSseStream>();
const STARRED_PROJECT_ID = "starred";
const starredProjectItemsSelect = {
where: { projectId: STARRED_PROJECT_ID },
select: { createdAt: true },
take: 1,
} as const;
const chatSummarySelect = {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
projectItems: starredProjectItemsSelect,
} as const;
const searchSummarySelect = {
id: true,
title: true,
query: true,
createdAt: true,
updatedAt: true,
projectItems: starredProjectItemsSelect,
} as const;
function getErrorMessage(err: unknown) { function getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
} }
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
const star = item.projectItems?.[0];
return {
starred: Boolean(star),
starredAt: star?.createdAt ?? null,
};
}
function serializeChatLike<T extends Record<string, any>>(chat: T) {
const { projectItems: _projectItems, ...rest } = chat;
return {
...serializeProviderFields(rest),
...serializeStarFields(chat),
};
}
function serializeSearchLike<T extends Record<string, any>>(search: T) {
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
return {
...rest,
...serializeStarFields(search),
};
}
async function ensureStarredProject() {
await prisma.project.upsert({
where: { id: STARRED_PROJECT_ID },
update: {},
create: {
id: STARRED_PROJECT_ID,
kind: "starred" as any,
title: "Starred",
},
});
}
async function getChatSummary(chatId: string) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: chatSummarySelect,
});
return chat ? serializeChatLike(chat) : null;
}
async function getSearchSummary(searchId: string) {
const search = await prisma.search.findUnique({
where: { id: searchId },
select: searchSummarySelect,
});
return search ? serializeSearchLike(search) : null;
}
async function setChatStarred(chatId: string, starred: boolean) {
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, chatId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
}
return getChatSummary(chatId);
}
async function setSearchStarred(searchId: string, starred: boolean) {
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, searchId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
}
return getSearchSummary(searchId);
}
async function listWorkspaceItems() {
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
}),
prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: searchSummarySelect,
}),
]);
return [
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
].sort(compareUpdatedAtDesc);
}
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) { function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
if (reply.raw.destroyed || reply.raw.writableEnded) return; if (reply.raw.destroyed || reply.raw.writableEnded) return;
reply.raw.write(`event: ${event.event}\n`); reply.raw.write(`event: ${event.event}\n`);
@@ -506,6 +730,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
where: { id: searchId }, where: { id: searchId },
data: { data: {
query, query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle, title: normalizedTitle,
requestId: searchResponse?.requestId ?? null, requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any, rawResponse: searchResponse as any,
@@ -526,12 +751,15 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
const search = await prisma.search.findUnique({ const search = await prisma.search.findUnique({
where: { id: searchId }, where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } }, include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
}); });
if (!search) { if (!search) {
stream.complete({ event: "error", data: { message: "search not found" } }); stream.complete({ event: "error", data: { message: "search not found" } });
} else { } else {
stream.complete({ event: "done", data: { search } }); stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
} }
} catch (err) { } catch (err) {
const message = getErrorMessage(err); const message = getErrorMessage(err);
@@ -540,6 +768,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
where: { id: searchId }, where: { id: searchId },
data: { data: {
query, query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle, title: normalizedTitle,
latencyMs: Math.round(performance.now() - startedAt), latencyMs: Math.round(performance.now() - startedAt),
error: message, error: message,
@@ -567,6 +796,11 @@ export async function registerRoutes(app: FastifyInstance) {
return { providers: getModelCatalogSnapshot() }; return { providers: getModelCatalogSnapshot() };
}); });
app.get("/v1/chat-tools", async (req) => {
requireAdmin(req);
return { tools: getAvailableChatTools() };
});
app.get("/v1/active-runs", async (req) => { app.get("/v1/active-runs", async (req) => {
requireAdmin(req); requireAdmin(req);
return { return {
@@ -575,23 +809,19 @@ export async function registerRoutes(app: FastifyInstance) {
}; };
}); });
app.get("/v1/workspace-items", async (req) => {
requireAdmin(req);
return { items: await listWorkspaceItems() };
});
app.get("/v1/chats", async (req) => { app.get("/v1/chats", async (req) => {
requireAdmin(req); requireAdmin(req);
const chats = await prisma.chat.findMany({ const chats = await prisma.chat.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 100, take: 100,
select: { select: chatSummarySelect,
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
}); });
return { chats }; return { chats: chats.map((chat) => serializeChatLike(chat)) };
}); });
app.post("/v1/chats", async (req) => { app.post("/v1/chats", async (req) => {
@@ -599,8 +829,10 @@ export async function registerRoutes(app: FastifyInstance) {
const Body = z const Body = z
.object({ .object({
title: z.string().optional(), title: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]).optional(), provider: ProviderSchema.optional(),
model: z.string().trim().min(1).optional(), model: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
messages: z.array(CompletionMessageSchema).optional(), messages: z.array(CompletionMessageSchema).optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
@@ -625,10 +857,12 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.create({ const chat = await prisma.chat.create({
data: { data: {
title: body.title, title: body.title,
initiatedProvider: body.provider as any, initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
initiatedModel: body.model, initiatedModel: body.model,
lastUsedProvider: body.provider as any, lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model, lastUsedModel: body.model,
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
enabledTools: body.enabledTools as any,
messages: body.messages?.length messages: body.messages?.length
? { ? {
create: body.messages.map((message) => ({ create: body.messages.map((message) => ({
@@ -640,47 +874,47 @@ export async function registerRoutes(app: FastifyInstance) {
} }
: undefined, : undefined,
}, },
select: { select: chatSummarySelect,
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
}); });
return { chat }; return { chat: serializeChatLike(chat) };
}); });
app.patch("/v1/chats/:chatId", async (req) => { app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ chatId: z.string() }); const Params = z.object({ chatId: z.string() });
const Body = z.object({ title: z.string().trim().min(1) }); const Body = z.object({
title: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
enabledTools: EnabledToolsSchema.optional(),
});
const { chatId } = Params.parse(req.params); const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {}); const body = Body.parse(req.body ?? {});
const data: Record<string, unknown> = {};
if (body.title !== undefined) data.title = body.title;
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
const updated = await prisma.chat.updateMany({ const updated = await prisma.chat.updateMany({
where: { id: chatId }, where: { id: chatId },
data: { title: body.title }, data: data as any,
}); });
if (updated.count === 0) return app.httpErrors.notFound("chat not found"); if (updated.count === 0) return app.httpErrors.notFound("chat not found");
const chat = await prisma.chat.findUnique({ const chat = await getChatSummary(chatId);
where: { id: chatId }, if (!chat) return app.httpErrors.notFound("chat not found");
select: { return { chat };
id: true, });
title: true,
createdAt: true, app.patch("/v1/chats/:chatId/star", async (req) => {
updatedAt: true, requireAdmin(req);
initiatedProvider: true, const Params = z.object({ chatId: z.string() });
initiatedModel: true, const Body = z.object({ starred: z.boolean() });
lastUsedProvider: true, const { chatId } = Params.parse(req.params);
lastUsedModel: true, const body = Body.parse(req.body ?? {});
},
}); const chat = await setChatStarred(chatId, body.starred);
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
return { chat }; return { chat };
}); });
@@ -695,39 +929,23 @@ export async function registerRoutes(app: FastifyInstance) {
const existing = await prisma.chat.findUnique({ const existing = await prisma.chat.findUnique({
where: { id: body.chatId }, where: { id: body.chatId },
select: { select: chatSummarySelect,
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
}); });
if (!existing) return app.httpErrors.notFound("chat not found"); if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: existing }; if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat"; const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content); const suggestedRaw = await generateChatTitle(body.content);
const title = normalizeSuggestedTitle(suggestedRaw, fallback); const title = normalizeSuggestedTitle(suggestedRaw, fallback);
const chat = await prisma.chat.update({ await prisma.chat.updateMany({
where: { id: body.chatId }, where: { id: body.chatId, title: existing.title },
data: { title }, data: { title },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
}); });
const chat = await getChatSummary(body.chatId);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat }; return { chat };
}); });
@@ -753,24 +971,69 @@ export async function registerRoutes(app: FastifyInstance) {
const searches = await prisma.search.findMany({ const searches = await prisma.search.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 100, take: 100,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true }, select: searchSummarySelect,
}); });
return { searches }; return { searches: searches.map((search) => serializeSearchLike(search)) };
}); });
app.post("/v1/searches", async (req) => { app.post("/v1/searches", async (req) => {
requireAdmin(req); requireAdmin(req);
const Body = z.object({ title: z.string().optional(), query: z.string().optional() }); const Body = z.object({
title: z.string().optional(),
query: z.string().optional(),
reuseByQuery: z.boolean().optional(),
});
const body = Body.parse(req.body ?? {}); const body = Body.parse(req.body ?? {});
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80); const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
const query = body.query?.trim() || null; const query = body.query?.trim() || null;
const queryNormalized = normalizeSearchQuery(query);
if (body.reuseByQuery && queryNormalized) {
const existing = await prisma.search.findFirst({
where: { queryNormalized },
orderBy: { updatedAt: "desc" },
select: {
...searchSummarySelect,
answerText: true,
_count: { select: { results: true } },
},
});
if (existing) {
const { _count, answerText: _answerText, ...search } = existing;
return {
search: serializeSearchLike(search),
reused: true,
cacheHit: isFreshSearchCacheHit({
updatedAt: existing.updatedAt,
resultCount: _count.results,
answerText: existing.answerText,
isActive: activeSearchStreams.has(existing.id),
}),
};
}
}
const search = await prisma.search.create({ const search = await prisma.search.create({
data: { data: {
title: title || null, title: title || null,
query, query,
queryNormalized,
}, },
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true }, select: searchSummarySelect,
}); });
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
});
app.patch("/v1/searches/:searchId/star", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ starred: z.boolean() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await setSearchStarred(searchId, body.starred);
if (!search) return app.httpErrors.notFound("search not found");
return { search }; return { search };
}); });
@@ -797,10 +1060,13 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({ const search = await prisma.search.findUnique({
where: { id: searchId }, where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } }, include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
}); });
if (!search) return app.httpErrors.notFound("search not found"); if (!search) return app.httpErrors.notFound("search not found");
return { search }; return { search: serializeSearchLike(search) };
}); });
app.post("/v1/searches/:searchId/chat", async (req) => { app.post("/v1/searches/:searchId/chat", async (req) => {
@@ -836,19 +1102,10 @@ export async function registerRoutes(app: FastifyInstance) {
}, },
}, },
}, },
select: { select: chatSummarySelect,
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
}); });
return { chat }; return { chat: serializeChatLike(chat) };
}); });
app.post("/v1/searches/:searchId/run", async (req) => { app.post("/v1/searches/:searchId/run", async (req) => {
@@ -909,6 +1166,7 @@ export async function registerRoutes(app: FastifyInstance) {
where: { id: searchId }, where: { id: searchId },
data: { data: {
query, query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle, title: normalizedTitle,
requestId: searchResponse?.requestId ?? null, requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any, rawResponse: searchResponse as any,
@@ -933,10 +1191,13 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({ const search = await prisma.search.findUnique({
where: { id: searchId }, where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } }, include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
}); });
if (!search) return app.httpErrors.notFound("search not found"); if (!search) return app.httpErrors.notFound("search not found");
return { search }; return { search: serializeSearchLike(search) };
} catch (err: any) { } catch (err: any) {
await prisma.search.update({ await prisma.search.update({
where: { id: searchId }, where: { id: searchId },
@@ -991,10 +1252,14 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
where: { id: chatId }, where: { id: chatId },
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } }, include: {
messages: { orderBy: { createdAt: "asc" } },
calls: { orderBy: { createdAt: "desc" } },
projectItems: starredProjectItemsSelect,
},
}); });
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
return { chat }; return { chat: serializeChatLike(chat) };
}); });
app.post("/v1/chats/:chatId/messages", async (req) => { app.post("/v1/chats/:chatId/messages", async (req) => {
@@ -1041,16 +1306,19 @@ export async function registerRoutes(app: FastifyInstance) {
const Body = z.object({ const Body = z.object({
chatId: z.string().optional(), chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]), provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}); });
const parsed = Body.safeParse(req.body); const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1063,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) {
await storeNonAssistantMessages(body.chatId, body.messages); await storeNonAssistantMessages(body.chatId, body.messages);
} }
const result = await runMultiplex(body); const result = await runMultiplex(await applyStoredChatSettings(body));
return { return {
chatId: body.chatId ?? null, chatId: body.chatId ?? null,
@@ -1077,7 +1345,7 @@ export async function registerRoutes(app: FastifyInstance) {
const parsed = CompletionStreamBody.safeParse(req.body); const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1094,14 +1362,14 @@ export async function registerRoutes(app: FastifyInstance) {
if (activeChatStreams.has(body.chatId)) { if (activeChatStreams.has(body.chatId)) {
return app.httpErrors.conflict("chat completion already running"); return app.httpErrors.conflict("chat completion already running");
} }
const stream = startActiveChatStream(body.chatId, body); const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
return streamActiveRun(req, reply, stream); return streamActiveRun(req, reply, stream);
} }
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders(); reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(body)) { for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
writeSseEvent(reply, mapChatStreamEvent(ev)); writeSseEvent(reply, mapChatStreamEvent(ev));
} }

View 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;
}

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
type ToolAwareStreamingEvent, type ToolAwareStreamingEvent,
@@ -105,3 +106,103 @@ 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"); assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
}); });
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
let requestBody: any = null;
const client = {
chat: {
completions: {
create: async (body: any) => {
requestBody = body;
return streamFrom([
{ choices: [{ delta: { content: "Hi" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]);
},
},
},
};
const events = await collectEvents(
runPlainChatCompletionsStream({
client: client as any,
model: "hermes-agent",
messages: [{ role: "user", content: "Say hi" }],
})
);
assert.equal(requestBody.model, "hermes-agent");
assert.equal(requestBody.stream, true);
assert.equal("tools" in requestBody, false);
assert.deepEqual(
events.map((event) => event.type),
["delta", "done"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
});
test("OpenAI-compatible Chat Completions 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(
runToolAwareChatCompletionsStream({
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");
});

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } 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("Anthropic system prompt includes runtime context with existing system messages", () => {
const prompt = getAnthropicSystemPrompt(
[{ 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\./);
});

View File

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

View File

@@ -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);
});

View File

@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787` - `SYBIL_TUI_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_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_DEFAULT_MODEL`: optional default model name
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`) - `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)

View File

@@ -10,6 +10,7 @@ import type {
SearchStreamHandlers, SearchStreamHandlers,
SearchSummary, SearchSummary,
SessionStatus, SessionStatus,
WorkspaceItem,
} from "./types.js"; } from "./types.js";
type RequestOptions = { type RequestOptions = {
@@ -41,6 +42,11 @@ export class SybilApiClient {
return data.chats; return data.chats;
} }
async listWorkspaceItems() {
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
async createChat(title?: string) { async createChat(title?: string) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", { const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
method: "POST", method: "POST",
@@ -54,6 +60,22 @@ export class SybilApiClient {
return data.chat; 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 }) { async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", { const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST", method: "POST",
@@ -84,6 +106,14 @@ export class SybilApiClient {
return data.search; 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) { async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
} }
@@ -94,6 +124,7 @@ export class SybilApiClient {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }

View File

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

View File

@@ -11,6 +11,7 @@ import type {
SearchDetail, SearchDetail,
SearchSummary, SearchSummary,
ToolCallEvent, ToolCallEvent,
WorkspaceItem,
} from "./types.js"; } from "./types.js";
type SidebarSelection = { kind: "chat" | "search"; id: string }; type SidebarSelection = { kind: "chat" | "search"; id: string };
@@ -19,6 +20,8 @@ type SidebarItem = SidebarSelection & {
title: string; title: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null; initiatedProvider: Provider | null;
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
@@ -29,7 +32,7 @@ type ToolLogMetadata = {
kind: "tool_call"; kind: "tool_call";
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
status?: "completed" | "failed"; status?: "initiated" | "completed" | "failed";
summary?: string; summary?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
startedAt?: string; startedAt?: string;
@@ -39,11 +42,13 @@ type ToolLogMetadata = {
resultPreview?: string | null; 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[]> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
"hermes-agent": ["hermes-agent"],
}; };
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
@@ -74,6 +79,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI"; if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic"; if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI"; if (provider === "xai") return "xAI";
if (provider === "hermes-agent") return "Hermes Agent";
return ""; return "";
} }
@@ -90,33 +96,67 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search"; return "New search";
} }
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
const items: SidebarItem[] = [ return { type: "chat", ...chat };
...chats.map((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, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
createdAt: chat.createdAt, createdAt: chat.createdAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider, initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
})), };
...searches.map((search) => ({ }
const search = item;
return {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
updatedAt: search.updatedAt, updatedAt: search.updatedAt,
createdAt: search.createdAt, createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null, initiatedProvider: null,
initiatedModel: null, initiatedModel: null,
lastUsedProvider: null, lastUsedProvider: null,
lastUsedModel: null, lastUsedModel: null,
})), };
]; });
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
} }
function asToolLogMetadata(value: unknown): ToolLogMetadata | null { function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -131,34 +171,57 @@ function isToolCallLogMessage(message: Message) {
} }
function buildOptimisticToolMessage(event: ToolCallEvent): Message { function buildOptimisticToolMessage(event: ToolCallEvent): Message {
const metadata: ToolLogMetadata = {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
};
if (event.completedAt) metadata.completedAt = event.completedAt;
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
return { return {
id: `temp-tool-${event.toolCallId}`, id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? new Date().toISOString(), createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
role: "tool", role: "tool",
content: event.summary, content: event.summary,
name: event.name, name: event.name,
metadata: { metadata,
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
}; };
} }
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
const toolMessage = buildOptimisticToolMessage(event);
const existingIndex = messages.findIndex(
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
);
if (existingIndex >= 0) {
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
}
const assistantIndex = messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) return messages.concat(toolMessage);
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
}
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
const providerModels = catalog[provider]?.models ?? []; const providerModels = catalog[provider]?.models ?? [];
if (providerModels.length) return providerModels; if (providerModels.length) return providerModels;
return PROVIDER_FALLBACK_MODELS[provider]; 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) { function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
if (fallback && options.includes(fallback)) return fallback; if (fallback && options.includes(fallback)) return fallback;
if (preferred && options.includes(preferred)) return preferred; if (preferred && options.includes(preferred)) return preferred;
@@ -188,6 +251,7 @@ async function main() {
let authMode: "open" | "token" | null = null; let authMode: "open" | "token" | null = null;
let chats: ChatSummary[] = []; let chats: ChatSummary[] = [];
let searches: SearchSummary[] = []; let searches: SearchSummary[] = [];
let workspaceItems: WorkspaceItem[] = [];
let selectedItem: SidebarSelection | null = null; let selectedItem: SidebarSelection | null = null;
let selectedChat: ChatDetail | null = null; let selectedChat: ChatDetail | null = null;
let selectedSearch: SearchDetail | null = null; let selectedSearch: SearchDetail | null = null;
@@ -202,6 +266,7 @@ async function main() {
openai: null, openai: null,
anthropic: null, anthropic: null,
xai: null, xai: null,
"hermes-agent": null,
}; };
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
let errorMessage: string | null = null; let errorMessage: string | null = null;
@@ -214,6 +279,7 @@ async function main() {
let renderedSidebarItems: SidebarItem[] = []; let renderedSidebarItems: SidebarItem[] = [];
let renderedSidebarLines: string[] = []; let renderedSidebarLines: string[] = [];
let suppressedSidebarSelectEvents = 0; let suppressedSidebarSelectEvents = 0;
let isRenamePromptOpen = false;
const screen = blessed.screen({ const screen = blessed.screen({
smartCSR: true, 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; const focusables = [sidebar, transcript, composer] as const;
function getTranscriptViewportHeight() { function getTranscriptViewportHeight() {
@@ -369,7 +455,7 @@ async function main() {
} }
function getSidebarItems() { function getSidebarItems() {
return buildSidebarItems(chats, searches); return buildSidebarItems(workspaceItems);
} }
function getSelectedChatSummary() { function getSelectedChatSummary() {
@@ -460,12 +546,13 @@ async function main() {
? ["No chats/searches yet. Press n or /. "] ? ["No chats/searches yet. Press n or /. "]
: items.map((item) => { : items.map((item) => {
const kind = item.kind === "chat" ? "C" : "S"; const kind = item.kind === "chat" ? "C" : "S";
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
const title = truncate(item.title, 36); const title = truncate(item.title, 36);
const initiatedLabel = const initiatedLabel =
item.kind === "chat" && item.initiatedModel item.kind === "chat" && item.initiatedModel
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}` ? ` | ${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 = const linesChanged =
@@ -534,7 +621,12 @@ async function main() {
for (const message of messages) { for (const message of messages) {
const toolMeta = asToolLogMetadata(message.metadata); const toolMeta = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolMeta) { 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."; const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
parts.push(`${prefix} ${escapeTags(summary)}`); parts.push(`${prefix} ${escapeTags(summary)}`);
continue; continue;
@@ -640,7 +732,7 @@ async function main() {
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`; const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
let controls = 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) { 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 += `\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}" : ""; controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
@@ -693,6 +785,7 @@ async function main() {
function resetWorkspaceState() { function resetWorkspaceState() {
chats = []; chats = [];
searches = []; searches = [];
workspaceItems = [];
selectedItem = null; selectedItem = null;
selectedChat = null; selectedChat = null;
selectedSearch = null; selectedSearch = null;
@@ -759,11 +852,13 @@ async function main() {
updateUI(); updateUI();
try { 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; chats = nextChats;
searches = nextSearches; searches = nextSearches;
const nextItems = buildSidebarItems(nextChats, nextSearches); const nextItems = buildSidebarItems(nextWorkspaceItems);
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
selectedItem = options.preferredSelection; selectedItem = options.preferredSelection;
draftKind = null; draftKind = null;
@@ -799,6 +894,27 @@ async function main() {
composer.readInput(); 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) { function cycleFocus(step: 1 | -1) {
const focused = screen.focused; const focused = screen.focused;
const currentIndex = focusables.findIndex((node) => node === focused); const currentIndex = focusables.findIndex((node) => node === focused);
@@ -867,9 +983,20 @@ async function main() {
pendingTitleGeneration.add(chatId); pendingTitleGeneration.add(chatId);
try { try {
const updated = await api.suggestChatTitle({ chatId, content }); 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) { 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(); updateUI();
} catch { } catch {
@@ -912,6 +1039,7 @@ async function main() {
chatId = chat.id; chatId = chat.id;
draftKind = null; draftKind = null;
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
selectedItem = { kind: "chat", id: chat.id }; selectedItem = { kind: "chat", id: chat.id };
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
selectedChat = { selectedChat = {
@@ -919,6 +1047,8 @@ async function main() {
title: chat.title, title: chat.title,
createdAt: chat.createdAt, createdAt: chat.createdAt,
updatedAt: chat.updatedAt, updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider, initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
@@ -977,29 +1107,7 @@ async function main() {
}, },
onToolCall: (payload) => { onToolCall: (payload) => {
if (!pendingChatState) return; if (!pendingChatState) return;
const alreadyPresent = pendingChatState.messages.some( pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
);
if (alreadyPresent) return;
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = pendingChatState.messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) {
pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) };
} else {
pendingChatState = {
...pendingChatState,
messages: [
...pendingChatState.messages.slice(0, assistantIndex),
toolMessage,
...pendingChatState.messages.slice(assistantIndex),
],
};
}
queueTranscriptScrollToBottomIfFollowing(); queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
@@ -1077,6 +1185,7 @@ async function main() {
draftKind = null; draftKind = null;
selectedItem = { kind: "search", id: searchId }; selectedItem = { kind: "search", id: searchId };
searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
selectedChat = null; selectedChat = null;
forceScrollToBottom = true; forceScrollToBottom = true;
updateUI(); updateUI();
@@ -1094,6 +1203,8 @@ async function main() {
query, query,
createdAt: nowIso, createdAt: nowIso,
updatedAt: nowIso, updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null, requestId: null,
latencyMs: null, latencyMs: null,
error: null, error: null,
@@ -1256,9 +1367,93 @@ async function main() {
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }); 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() { function cycleProvider() {
const currentIndex = PROVIDERS.indexOf(provider); const visibleProviders = getVisibleProviders(modelCatalog);
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai"; 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; provider = nextProvider;
syncModelForProvider(); syncModelForProvider();
updateUI(); updateUI();
@@ -1339,18 +1534,18 @@ async function main() {
}); });
screen.key(["q"], () => { screen.key(["q"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
screen.destroy(); screen.destroy();
process.exit(0); process.exit(0);
}); });
screen.key(["tab"], () => { screen.key(["tab"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
cycleFocus(1); cycleFocus(1);
}); });
screen.key(["S-tab", "backtab"], () => { screen.key(["S-tab", "backtab"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
cycleFocus(-1); cycleFocus(-1);
}); });
@@ -1367,36 +1562,50 @@ async function main() {
}); });
screen.key(["n"], () => { screen.key(["n"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
handleCreateChat(); handleCreateChat();
}); });
screen.key(["/"], () => { screen.key(["/"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
handleCreateSearch(); handleCreateSearch();
}); });
screen.key(["d"], () => { screen.key(["d"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => { void runAction(async () => {
await handleDeleteSelection(); await handleDeleteSelection();
}); });
}); });
screen.key(["s"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleToggleStarSelection();
});
});
screen.key(["p"], () => { screen.key(["p"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
if (getIsSearchMode() || isSending) return; if (getIsSearchMode() || isSending) return;
cycleProvider(); cycleProvider();
}); });
screen.key(["m"], () => { screen.key(["m"], () => {
if (isTextInputFocused(screen, composer)) return; if (shouldIgnoreGlobalShortcut()) return;
if (getIsSearchMode() || isSending) return; if (getIsSearchMode() || isSending) return;
cycleModel(); cycleModel();
}); });
screen.key(["r"], () => { 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 () => { void runAction(async () => {
await refreshCollections({ loadSelection: true }); await refreshCollections({ loadSelection: true });
await refreshModels(); await refreshModels();

View File

@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai"; export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
}; };
export type ModelCatalogResponse = { export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>; providers: Partial<Record<Provider, ProviderModelInfo>>;
}; };
export type ChatSummary = { export type ChatSummary = {
@@ -15,6 +15,8 @@ export type ChatSummary = {
title: string | null; title: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null; initiatedProvider: Provider | null;
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
@@ -27,8 +29,20 @@ export type SearchSummary = {
query: string | null; query: string | null;
createdAt: string; createdAt: string;
updatedAt: 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 = { export type Message = {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -41,12 +55,12 @@ export type Message = {
export type ToolCallEvent = { export type ToolCallEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: "initiated" | "completed" | "failed";
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };
@@ -56,6 +70,8 @@ export type ChatDetail = {
title: string | null; title: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null; initiatedProvider: Provider | null;
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
@@ -85,6 +101,8 @@ export type SearchDetail = {
query: string | null; query: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null; requestId: string | null;
latencyMs: number | null; latencyMs: number | null;
error: string | null; error: string | null;

View File

@@ -3,12 +3,18 @@
<head> <head>
<meta charset="UTF-8" /> <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="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="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-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="apple-mobile-web-app-title" content="Sybil" />
<meta name="format-detection" content="telephone=no" />
<link rel="manifest" href="/manifest.webmanifest" /> <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" /> <link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
<title>Sybil</title> <title>Sybil</title>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -1,9 +1,32 @@
{ {
"id": "/",
"name": "Sybil", "name": "Sybil",
"short_name": "Sybil", "short_name": "Sybil",
"description": "Sybil chat and search workspace",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "fullscreen",
"background_color": "#ffffff", "display_override": ["fullscreen", "standalone"],
"theme_color": "#0f172a" "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
View 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));
});

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ type Props = {
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) { export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
return ( 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="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
<div className="mb-6"> <div className="mb-6">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent"> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">

View File

@@ -14,7 +14,7 @@ type ToolLogMetadata = {
kind: "tool_call"; kind: "tool_call";
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
status?: "completed" | "failed"; status?: "initiated" | "completed" | "failed";
summary?: string; summary?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
startedAt?: string; startedAt?: string;
@@ -71,9 +71,17 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value)); 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";
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 [ return [
isFailed ? "Failed" : "Completed", state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
formatDuration(metadata.durationMs), formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt), formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
] ]
@@ -93,10 +101,12 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
if (message.role === "tool" && toolLogMetadata) { if (message.role === "tool" && toolLogMetadata) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name); const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench; 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 toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata); const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed); const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return ( return (
<div key={message.id} className="flex justify-start"> <div key={message.id} className="flex justify-start">
<div <div
@@ -104,6 +114,8 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
"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 max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed isFailed
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]" ? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]" : "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
)} )}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`} title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
@@ -111,7 +123,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
<span <span
className={cn( className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border", "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" /> <Icon className="h-4 w-4" />
@@ -121,7 +137,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
{toolSummary} {toolSummary}
</span> </span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4"> <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} {toolLabel}
</span> </span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span> <span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>

View File

@@ -14,6 +14,10 @@
:root { :root {
color-scheme: dark; 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%; --background: 235 45% 4%;
--foreground: 258 36% 96%; --foreground: 258 36% 96%;
--muted: 246 30% 13%; --muted: 246 30% 13%;
@@ -40,6 +44,15 @@ html,
body, body,
#app { #app {
height: 100%; height: 100%;
width: 100%;
}
@supports (height: 100dvh) {
html,
body,
#app {
height: 100dvh;
}
} }
body { body {
@@ -49,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(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%)); 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; font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
overflow: hidden;
overscroll-behavior: none;
} }
button, button,
@@ -78,6 +93,44 @@ textarea {
background-size: 48px 48px; 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 { .glass-panel {
background: background:
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)), linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
@@ -206,17 +259,31 @@ textarea {
} }
.md-content code { .md-content code {
background: hsl(288 22% 23%); background: hsl(249 40% 10% / 0.78);
border-radius: 0.25rem; border-radius: 0.3rem;
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-size: 0.86em; font-size: 0.86em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
} }
.md-content pre { .md-content pre {
overflow-x: auto; overflow-x: auto;
border-radius: 0.5rem; border: 1px solid hsl(253 31% 29% / 0.72);
background: hsl(287 28% 13%); border-radius: 0.625rem;
padding: 0.6rem 0.75rem; 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 { .md-content a {

View File

@@ -3,10 +3,14 @@ export type ChatSummary = {
title: string | null; title: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null; initiatedProvider: Provider | null;
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
}; };
export type SearchSummary = { export type SearchSummary = {
@@ -15,8 +19,20 @@ export type SearchSummary = {
query: string | null; query: string | null;
createdAt: string; createdAt: string;
updatedAt: 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 = { export type Message = {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -29,12 +45,12 @@ export type Message = {
export type ToolCallEvent = { export type ToolCallEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: "initiated" | "completed" | "failed";
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };
@@ -44,10 +60,14 @@ export type ChatDetail = {
title: string | null; title: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null; initiatedProvider: Provider | null;
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
messages: Message[]; messages: Message[];
}; };
@@ -73,6 +93,8 @@ export type SearchDetail = {
query: string | null; query: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null; requestId: string | null;
latencyMs: number | null; latencyMs: number | null;
error: string | null; error: string | null;
@@ -127,7 +149,7 @@ export type CompletionRequestMessage = {
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
}; };
export type Provider = "openai" | "anthropic" | "xai"; export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -136,7 +158,12 @@ export type ProviderModelInfo = {
}; };
export type ModelCatalogResponse = { export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>; providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ChatToolInfo = {
name: string;
description: string;
}; };
export type ActiveRunsResponse = { export type ActiveRunsResponse = {
@@ -164,9 +191,23 @@ type CreateChatRequest = {
title?: string; title?: string;
provider?: Provider; provider?: Provider;
model?: string; model?: string;
additionalSystemPrompt?: string;
enabledTools?: string[];
messages?: CompletionRequestMessage[]; 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 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; const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN; let authToken: string | null = ENV_ADMIN_TOKEN;
@@ -214,6 +255,11 @@ export async function listChats() {
return data.chats; return data.chats;
} }
export async function listWorkspaceItems() {
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
export async function verifySession() { export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
} }
@@ -222,6 +268,11 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models"); 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() { export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs"); return api<ActiveRunsResponse>("/v1/active-runs");
} }
@@ -248,6 +299,25 @@ export async function updateChatTitle(chatId: string, title: string) {
return data.chat; 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 }) { export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", { const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST", method: "POST",
@@ -265,19 +335,35 @@ export async function listSearches() {
return data.searches; return data.searches;
} }
export async function createSearch(body?: { title?: string; query?: string }) { async function postSearch(body?: CreateSearchRequest) {
const data = await api<{ search: SearchSummary }>("/v1/searches", { return api<CreateSearchResponse>("/v1/searches", {
method: "POST", method: "POST",
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
}); });
}
export async function createSearch(body?: CreateSearchRequest) {
const data = await postSearch(body);
return data.search; return data.search;
} }
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
return postSearch({ ...body, reuseByQuery: true });
}
export async function getSearch(searchId: string) { export async function getSearch(searchId: string) {
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`); const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
return data.search; 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 }) { export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, { const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST", method: "POST",
@@ -554,6 +640,9 @@ export async function runCompletion(body: {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}) { }) {
return api<CompletionResponse>("/v1/chat-completions", { return api<CompletionResponse>("/v1/chat-completions", {
method: "POST", method: "POST",
@@ -568,6 +657,9 @@ export async function runCompletionStream(
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }

View File

@@ -1,5 +1,8 @@
import { render } from "preact"; import { render } from "preact";
import { RootRouter } from "@/root-router"; import { RootRouter } from "@/root-router";
import { registerServiceWorker } from "@/pwa";
import "./index.css"; import "./index.css";
registerServiceWorker();
render(<RootRouter />, document.getElementById("app")!); render(<RootRouter />, document.getElementById("app")!);

View File

@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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"; import { useSessionAuth } from "@/hooks/use-session-auth";
function readQueryFromUrl() { function readQueryFromUrl() {
@@ -85,14 +85,16 @@ export default function SearchRoutePage() {
const runQuery = async (query: string) => { const runQuery = async (query: string) => {
const trimmed = query.trim(); const trimmed = query.trim();
const requestId = ++requestCounterRef.current;
streamAbortRef.current?.abort();
if (!trimmed) { if (!trimmed) {
setSearch(null); setSearch(null);
setError(null); setError(null);
setIsRunning(false);
return; return;
} }
const requestId = ++requestCounterRef.current;
streamAbortRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
streamAbortRef.current = abortController; streamAbortRef.current = abortController;
let wasInterrupted = false; let wasInterrupted = false;
@@ -106,6 +108,8 @@ export default function SearchRoutePage() {
query: trimmed, query: trimmed,
createdAt: nowIso, createdAt: nowIso,
updatedAt: nowIso, updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null, requestId: null,
latencyMs: null, latencyMs: null,
error: null, error: null,
@@ -117,10 +121,11 @@ export default function SearchRoutePage() {
}); });
try { try {
const created = await createSearch({ const createdResult = await createReusableSearch({
query: trimmed, query: trimmed,
title: trimmed.slice(0, 80), title: trimmed.slice(0, 80),
}); });
const created = createdResult.search;
if (requestId !== requestCounterRef.current) return; if (requestId !== requestCounterRef.current) return;
setSearch((current) => setSearch((current) =>
@@ -132,10 +137,19 @@ export default function SearchRoutePage() {
query: created.query, query: created.query,
createdAt: created.createdAt, createdAt: created.createdAt,
updatedAt: created.updatedAt, updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
} }
: current : current
); );
if (createdResult.cacheHit) {
const cached = await getSearch(created.id);
if (requestId !== requestCounterRef.current) return;
setSearch(cached);
return;
}
await runSearchStream( await runSearchStream(
created.id, created.id,
{ {
@@ -248,7 +262,7 @@ export default function SearchRoutePage() {
} }
return ( 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"> <div className="mx-auto w-full max-w-4xl space-y-5">
<form <form
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm" className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"

9
web/src/pwa.ts Normal file
View 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);
});
});
}

View File

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