Compare commits
24 Commits
195e157e1a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
| 600bc3befc | |||
| 5b7ed25522 | |||
| 39014eee18 | |||
| a6c2ec664b | |||
| cb8ea935fa | |||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
143
docs/api/rest.md
143
docs/api/rest.md
@@ -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,
|
||||||
|
|||||||
@@ -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
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
|
FASTLANE_USER=you@example.com
|
||||||
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
|
||||||
|
# Optional App Store Connect API key settings for non-interactive upload and
|
||||||
|
# TestFlight build-number lookup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
11
ios/.gitignore
vendored
11
ios/.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
17
ios/Apps/Sybil/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>net.buzzert.sybil2.quick-question</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Quick question</string>
|
||||||
|
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||||
|
<string>sparkles</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane", "~> 2.227"
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SybilHomeScreenQuickAction {
|
||||||
|
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SybilQuickActionRouter: ObservableObject {
|
||||||
|
public static let shared = SybilQuickActionRouter()
|
||||||
|
|
||||||
|
@Published public private(set) var quickQuestionPresentationRequest = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func requestQuickQuestionPresentation() {
|
||||||
|
quickQuestionPresentationRequest += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilQuickQuestionView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var focusRequest: Int
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@FocusState private var promptFocused: Bool
|
||||||
|
|
||||||
|
private var hasAnswerContent: Bool {
|
||||||
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header
|
||||||
|
|
||||||
|
answerArea
|
||||||
|
|
||||||
|
composer
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.background(SybilTheme.backgroundGradient)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.task(id: focusRequest) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(260))
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
promptFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 21, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
|
||||||
|
Text("Quick question")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var answerArea: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if hasAnswerContent {
|
||||||
|
ForEach(viewModel.quickQuestionMessages) { message in
|
||||||
|
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.quickQuestionError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.black.opacity(0.36))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
|
TextField(
|
||||||
|
"Ask anything...",
|
||||||
|
text: Binding(
|
||||||
|
get: { viewModel.quickQuestionPrompt },
|
||||||
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
|
),
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.focused($promptFocused)
|
||||||
|
.font(.body)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.autocorrectionDisabled(false)
|
||||||
|
.lineLimit(1 ... 6)
|
||||||
|
.submitLabel(.send)
|
||||||
|
.onSubmit(submitQuestion)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.composerGradient)
|
||||||
|
.opacity(0.98)
|
||||||
|
)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Button(action: submitQuestion) {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
viewModel.canSendQuickQuestion
|
||||||
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!viewModel.canSendQuickQuestion)
|
||||||
|
.accessibilityLabel("Ask quick question")
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var convertButton: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||||
|
if didConvert {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Chat", systemImage: "bubble.left")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.disabled(!viewModel.canConvertQuickQuestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
providerMenu
|
||||||
|
modelMenu
|
||||||
|
convertButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.providerOptions, id: \.self) { provider in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionProvider(provider)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionProvider == provider {
|
||||||
|
Label(provider.displayName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(provider.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelMenu: some View {
|
||||||
|
Menu {
|
||||||
|
if viewModel.quickQuestionProviderModelOptions.isEmpty {
|
||||||
|
Text("No models")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionModel(model)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionModel == model {
|
||||||
|
Label(model, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question model")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitQuestion() {
|
||||||
|
guard viewModel.canSendQuickQuestion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFocused = false
|
||||||
|
_ = viewModel.sendQuickQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionPickerPill: View {
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionMessageView: View {
|
||||||
|
var message: Message
|
||||||
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var isPendingAssistant: Bool {
|
||||||
|
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||||
|
isSending &&
|
||||||
|
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let metadata = message.toolCallMetadata {
|
||||||
|
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else if isPendingAssistant {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Thinking...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Markdown(message.content)
|
||||||
|
.font(.body)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.text.opacity(0.96))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
|
||||||
|
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return fallbackContent
|
||||||
|
}
|
||||||
|
return "Ran \(metadata.toolName ?? "tool")."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,59 +111,108 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
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 {
|
var body: some View {
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
ProgressView()
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.tint(SybilTheme.primary)
|
ProgressView()
|
||||||
Text("Loading conversations…")
|
.tint(SybilTheme.primary)
|
||||||
.font(.sybil(.footnote))
|
Text("Loading conversations…")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.sybil(.footnote))
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
.padding(16)
|
||||||
VStack(spacing: 10) {
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
Image(systemName: "message.badge")
|
VStack(spacing: 10) {
|
||||||
.font(.system(size: 20, weight: .medium))
|
Image(systemName: "message.badge")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.system(size: 20, weight: .medium))
|
||||||
Text("Start a chat or run your first search.")
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.font(.sybil(.footnote))
|
Text("Start a chat or run your first search.")
|
||||||
.multilineTextAlignment(.center)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.multilineTextAlignment(.center)
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
.padding(16)
|
||||||
ScrollView {
|
} else {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
ScrollView {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
Button {
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
onSelect(item)
|
Button {
|
||||||
} label: {
|
onSelect(item)
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
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()
|
||||||
}
|
}
|
||||||
.padding(10)
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
}
|
||||||
await viewModel.refreshVisibleContent(
|
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||||
refreshCollections: true,
|
TextField("Title", text: $renameTitle)
|
||||||
refreshSelection: false
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +253,12 @@ struct SybilSidebarRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
if item.starred {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
if item.isRunning {
|
if item.isRunning {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
require "dotenv"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
177
ios/fastlane/Fastfile
Normal file
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(command)
|
||||||
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_project_settings
|
||||||
|
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_build_number
|
||||||
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = nil
|
||||||
|
|
||||||
|
if app_store_connect_key_options
|
||||||
|
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
if api_key
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store-connect",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "automatic",
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||||
|
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||||
|
|
||||||
|
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||||
|
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||||
|
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||||
|
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||||
|
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||||
|
|
||||||
|
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||||
|
sh([
|
||||||
|
"xcrun altool",
|
||||||
|
"--upload-package #{ipa_path.shellescape}",
|
||||||
|
"--platform ios",
|
||||||
|
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||||
|
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||||
|
"--bundle-version #{build_number.shellescape}",
|
||||||
|
"--bundle-short-version-string #{version.shellescape}",
|
||||||
|
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||||
|
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||||
|
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||||
|
"--show-progress"
|
||||||
|
].join(" "))
|
||||||
|
end
|
||||||
|
end
|
||||||
40
ios/fastlane/README.md
Normal file
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
12
ios/justfile
12
ios/justfile
@@ -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}}'
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'folder',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectItem" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"chatId" TEXT,
|
||||||
|
"searchId" TEXT,
|
||||||
|
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add normalized search query lookup key for cache/reuse behavior.
|
||||||
|
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
|
||||||
|
|
||||||
|
UPDATE "Search"
|
||||||
|
SET "queryNormalized" = lower(trim("query"))
|
||||||
|
WHERE "query" IS NOT NULL AND trim("query") != '';
|
||||||
|
|
||||||
|
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
|
||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
31
server/src/llm/provider-ids.ts
Normal file
31
server/src/llm/provider-ids.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
server/src/search-cache.ts
Normal file
29
server/src/search-cache.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||||
|
const normalized = value?.trim().toLowerCase() ?? "";
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||||
|
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFreshSearchCacheHit(
|
||||||
|
candidate: {
|
||||||
|
updatedAt: Date | string;
|
||||||
|
resultCount: number;
|
||||||
|
answerText?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
now = new Date(),
|
||||||
|
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
if (candidate.isActive) return false;
|
||||||
|
if (!hasReusableSearchPayload(candidate)) return false;
|
||||||
|
|
||||||
|
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||||
|
if (!Number.isFinite(updatedAtMs)) return false;
|
||||||
|
|
||||||
|
return now.getTime() - updatedAtMs <= ttlMs;
|
||||||
|
}
|
||||||
@@ -1,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");
|
||||||
|
});
|
||||||
|
|||||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSystemPromptAugmentation, 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\./);
|
||||||
|
});
|
||||||
12
server/tests/provider-ids.test.ts
Normal file
12
server/tests/provider-ids.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
|
||||||
|
|
||||||
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||||
|
initiatedProvider: "hermes-agent",
|
||||||
|
lastUsedProvider: "xai",
|
||||||
|
});
|
||||||
|
});
|
||||||
25
server/tests/search-cache.test.ts
Normal file
25
server/tests/search-cache.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||||
|
|
||||||
|
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||||
|
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||||
|
assert.equal(normalizeSearchQuery(" "), null);
|
||||||
|
assert.equal(normalizeSearchQuery(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||||
|
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
|
|||||||
|
|
||||||
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
- `SYBIL_TUI_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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
343
tui/src/index.ts
343
tui/src/index.ts
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
web/public/icons/favicon-32.png
Normal file
BIN
web/public/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
BIN
web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -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
12
web/public/sw.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.mode !== "navigate") return;
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
||||||
1105
web/src/App.tsx
1105
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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")!);
|
||||||
|
|||||||
@@ -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
9
web/src/pwa.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function registerServiceWorker() {
|
||||||
|
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||||
|
console.warn("Sybil service worker registration failed", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/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"}
|
||||||
Reference in New Issue
Block a user