Compare commits
75 Commits
d7967eaa75
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c425f664 | |||
| 297b053a91 | |||
| 7436544a69 | |||
| 95796646b1 | |||
| d7214c88ad | |||
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
| 600bc3befc | |||
| 5b7ed25522 | |||
| 39014eee18 | |||
| a6c2ec664b | |||
| cb8ea935fa | |||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 | |||
| 195e157e1a | |||
| c5dbd12587 | |||
| be072fd46d | |||
| f514c42de6 | |||
| 70a60edf1c | |||
| 91ef28bf29 | |||
| bb713f8806 | |||
| e6cf344527 | |||
| 4bc0773d35 | |||
| d1140d21d4 | |||
| 0c0226e37e | |||
| 0b94d5b3fa | |||
| aff2531bf3 | |||
| ee8a93a8c4 | |||
| 53a3b722ec | |||
| ae783020ef | |||
| 39acefb55a | |||
| e6fe63280a | |||
| 2403dd99ae | |||
| 89bd418566 | |||
| e02168854c | |||
| 3820007289 | |||
| 5d046ca173 | |||
| bca408c971 | |||
| 2f265fd847 | |||
| 29e340fd08 | |||
| 6fbcaecbf8 | |||
| 519ebd15dd | |||
| 8051dd2c71 | |||
| 2313e560e8 | |||
| 94565298d8 | |||
| 7360604136 | |||
| ca6b5e0807 | |||
| 4b0cc3fbf7 | |||
| 2da73f802c | |||
| 4ad36d9bf6 | |||
| cf9832ca3b | |||
| 2c32ca66e2 | |||
| 015253c0af | |||
| 8d6c069a33 | |||
| d579b5bf75 | |||
| 01ee807991 | |||
| fd9ee455fb | |||
| 38da3cea72 | |||
| 11e6875de9 | |||
| 5a690b276f |
@@ -24,6 +24,10 @@ COPY server/package.json server/package-lock.json ./
|
|||||||
COPY server/scripts ./scripts
|
COPY server/scripts ./scripts
|
||||||
COPY server/prisma ./prisma
|
COPY server/prisma ./prisma
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN npm ci --omit=dev --no-audit --no-fund
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
COPY --from=server-build /app/server/dist ./dist
|
COPY --from=server-build /app/server/dist ./dist
|
||||||
|
|||||||
6
dist/default.conf
vendored
@@ -1,6 +1,7 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
client_max_body_size 32m;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
@@ -16,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,11 +12,28 @@ 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:-}
|
||||||
|
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||||
|
CHAT_CODEX_TOOL_ENABLED: ${CHAT_CODEX_TOOL_ENABLED:-false}
|
||||||
|
CHAT_CODEX_REMOTE_HOST: ${CHAT_CODEX_REMOTE_HOST:-}
|
||||||
|
CHAT_CODEX_REMOTE_USER: ${CHAT_CODEX_REMOTE_USER:-}
|
||||||
|
CHAT_CODEX_REMOTE_PORT: ${CHAT_CODEX_REMOTE_PORT:-22}
|
||||||
|
CHAT_CODEX_REMOTE_WORKDIR: ${CHAT_CODEX_REMOTE_WORKDIR:-/workspace/sybil-codex}
|
||||||
|
# Prefer mounting a private key read-only and pointing CHAT_CODEX_SSH_KEY_PATH at it.
|
||||||
|
CHAT_CODEX_SSH_KEY_PATH: ${CHAT_CODEX_SSH_KEY_PATH:-}
|
||||||
|
CHAT_CODEX_SSH_PRIVATE_KEY_B64: ${CHAT_CODEX_SSH_PRIVATE_KEY_B64:-}
|
||||||
|
CHAT_CODEX_EXEC_TIMEOUT_MS: ${CHAT_CODEX_EXEC_TIMEOUT_MS:-600000}
|
||||||
|
CHAT_SHELL_TOOL_ENABLED: ${CHAT_SHELL_TOOL_ENABLED:-false}
|
||||||
|
CHAT_SHELL_EXEC_TIMEOUT_MS: ${CHAT_SHELL_EXEC_TIMEOUT_MS:-120000}
|
||||||
volumes:
|
volumes:
|
||||||
- sybil_data:/data
|
- sybil_data:/data
|
||||||
|
# Example key mount for codex_exec:
|
||||||
|
# - ./secrets/devbox_id_ed25519:/run/secrets/codex_ssh_key:ro
|
||||||
expose:
|
expose:
|
||||||
- "8787"
|
- "8787"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
312
docs/api/rest.md
@@ -10,6 +10,12 @@ Content type:
|
|||||||
- Requests with bodies use `application/json`.
|
- Requests with bodies use `application/json`.
|
||||||
- Responses are JSON unless noted otherwise.
|
- Responses are JSON unless noted otherwise.
|
||||||
|
|
||||||
|
Chat upload limits:
|
||||||
|
- Chat completion and direct message payloads support inline attachments up to a 32 MB request body.
|
||||||
|
- Up to 8 attachments per message.
|
||||||
|
- Image attachments: PNG or JPEG only, max 6 MB each.
|
||||||
|
- Text attachments: up to 8 MB source size each; server accepts at most 200,000 characters of inlined text content per attachment.
|
||||||
|
|
||||||
## Health + Auth
|
## Health + Auth
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /health`
|
||||||
@@ -27,10 +33,90 @@ Content type:
|
|||||||
"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.
|
||||||
|
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||||
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|
||||||
|
## Chat Tools
|
||||||
|
|
||||||
|
### `GET /v1/chat-tools`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{ "name": "web_search", "description": "..." },
|
||||||
|
{ "name": "fetch_url", "description": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||||
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
|
## Active Runs
|
||||||
|
|
||||||
|
### `GET /v1/active-runs`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chats": ["chat-id-with-active-stream"],
|
||||||
|
"searches": ["search-id-with-active-stream"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Lists in-memory chat/search streams that are still running on this server process.
|
||||||
|
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||||
|
- The lists are not durable across server restarts.
|
||||||
|
|
||||||
|
## Workspace Items
|
||||||
|
|
||||||
|
### `GET /v1/workspace-items`
|
||||||
|
- Response: `{ "items": WorkspaceItem[] }`
|
||||||
|
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "chat",
|
||||||
|
"id": "chat-id",
|
||||||
|
"title": "optional title",
|
||||||
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"starred": true,
|
||||||
|
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||||
|
"initiatedProvider": "openai",
|
||||||
|
"initiatedModel": "gpt-4.1-mini",
|
||||||
|
"lastUsedProvider": "openai",
|
||||||
|
"lastUsedModel": "gpt-4.1-mini",
|
||||||
|
"additionalSystemPrompt": null,
|
||||||
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "search",
|
||||||
|
"id": "search-id",
|
||||||
|
"title": "optional title",
|
||||||
|
"query": "search query",
|
||||||
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||||
|
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
|
- The response currently combines up to 100 chats and up to 100 searches.
|
||||||
|
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
@@ -38,14 +124,52 @@ Content type:
|
|||||||
- Response: `{ "chats": ChatSummary[] }`
|
- Response: `{ "chats": ChatSummary[] }`
|
||||||
|
|
||||||
### `POST /v1/chats`
|
### `POST /v1/chats`
|
||||||
- Body: `{ "title"?: string }`
|
- Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "optional title",
|
||||||
|
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||||
|
"model": "optional model id",
|
||||||
|
"additionalSystemPrompt": "optional stored system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system|user|assistant|tool",
|
||||||
|
"content": "string",
|
||||||
|
"name": "optional",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- `provider` and `model` must be supplied together when present.
|
||||||
|
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||||
|
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||||
|
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||||
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId`
|
### `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
|
||||||
@@ -58,7 +182,8 @@ Content type:
|
|||||||
|
|
||||||
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 }`
|
||||||
@@ -74,11 +199,34 @@ Behavior notes:
|
|||||||
"role": "system|user|assistant|tool",
|
"role": "system|user|assistant|tool",
|
||||||
"content": "string",
|
"content": "string",
|
||||||
"name": "optional",
|
"name": "optional",
|
||||||
"metadata": {}
|
"metadata": {},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"kind": "image",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "photo.jpg",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"sizeBytes": 12345,
|
||||||
|
"dataUrl": "data:image/jpeg;base64,..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "notes.md",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"sizeBytes": 4567,
|
||||||
|
"text": "# Notes\\n...",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- Response: `{ "message": Message }`
|
- Response: `{ "message": Message }`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `attachments` is optional and is merged into stored `message.metadata.attachments`.
|
||||||
|
- Tool messages should not include attachments.
|
||||||
|
|
||||||
## Chat Completions (non-streaming)
|
## Chat Completions (non-streaming)
|
||||||
|
|
||||||
### `POST /v1/chat-completions`
|
### `POST /v1/chat-completions`
|
||||||
@@ -86,11 +234,36 @@ Behavior 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": [
|
||||||
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
|
{
|
||||||
|
"role": "system|user|assistant|tool",
|
||||||
|
"content": "string",
|
||||||
|
"name": "optional",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"kind": "image",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "photo.jpg",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"sizeBytes": 12345,
|
||||||
|
"dataUrl": "data:image/jpeg;base64,..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "notes.md",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"sizeBytes": 4567,
|
||||||
|
"text": "# Notes\\n...",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -110,14 +283,39 @@ Behavior 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.
|
||||||
- For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
- Available tool calls for chat: `web_search` and `fetch_url`.
|
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
|
||||||
|
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||||
|
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||||
|
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||||
|
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||||
|
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||||
|
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||||
|
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||||
|
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
|
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||||
|
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output.
|
- `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.
|
||||||
- `anthropic` currently runs without server-managed tool calls.
|
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||||
|
- Devbox tool configuration:
|
||||||
|
- `CHAT_MAX_TOOL_ROUNDS=100` (optional; maximum model/tool result cycles before the backend returns a limit message)
|
||||||
|
- `CHAT_CODEX_TOOL_ENABLED=true`
|
||||||
|
- `CHAT_SHELL_TOOL_ENABLED=true`
|
||||||
|
- `CHAT_CODEX_REMOTE_HOST=<host-or-ip>` (required when enabled)
|
||||||
|
- `CHAT_CODEX_REMOTE_USER=<ssh-user>` (optional; omitted if `CHAT_CODEX_REMOTE_HOST` already contains `user@host`)
|
||||||
|
- `CHAT_CODEX_REMOTE_PORT=22` (optional)
|
||||||
|
- `CHAT_CODEX_REMOTE_WORKDIR=/workspace/sybil-codex` (optional; created on the remote host if missing)
|
||||||
|
- `CHAT_CODEX_SSH_KEY_PATH=/run/secrets/codex_ssh_key` (recommended private-key delivery via read-only volume mount)
|
||||||
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
||||||
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||||
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
|
|
||||||
@@ -125,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 }`
|
||||||
@@ -165,6 +379,32 @@ Search run notes:
|
|||||||
- Persists answer text/citations + ranked results.
|
- Persists answer text/citations + ranked results.
|
||||||
- If both search and answer fail, endpoint returns an error.
|
- If both search and answer fail, endpoint returns an error.
|
||||||
|
|
||||||
|
### `POST /v1/searches/:searchId/run/stream`
|
||||||
|
- Body: same as `POST /v1/searches/:searchId/run`
|
||||||
|
- Response: `text/event-stream`
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
|
||||||
|
- `search_error`: `{ "error": string }`
|
||||||
|
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
|
||||||
|
- `answer_error`: `{ "error": string }`
|
||||||
|
- terminal `done`: `{ "search": SearchDetail }`
|
||||||
|
- terminal `error`: `{ "message": string }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
|
||||||
|
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
|
||||||
|
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.
|
||||||
|
|
||||||
|
### `POST /v1/searches/:searchId/run/stream/attach`
|
||||||
|
- Body: none
|
||||||
|
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
|
||||||
|
- Not found: `404 { "message": "active search stream not found" }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||||
|
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.
|
||||||
|
|
||||||
## Type Shapes
|
## Type Shapes
|
||||||
|
|
||||||
`ChatSummary`
|
`ChatSummary`
|
||||||
@@ -174,10 +414,14 @@ Search run 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"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -189,10 +433,32 @@ Search run notes:
|
|||||||
"role": "system|user|assistant|tool",
|
"role": "system|user|assistant|tool",
|
||||||
"content": "...",
|
"content": "...",
|
||||||
"name": null,
|
"name": null,
|
||||||
"metadata": null
|
"metadata": {
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"kind": "image",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "photo.jpg",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"sizeBytes": 12345,
|
||||||
|
"dataUrl": "data:image/jpeg;base64,..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "notes.md",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"sizeBytes": 4567,
|
||||||
|
"text": "# Notes\\n...",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`metadata` remains nullable. Tool-call log messages still use `metadata.kind = "tool_call"`; regular user messages with attachments use `metadata.attachments`.
|
||||||
|
|
||||||
`ChatDetail`
|
`ChatDetail`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -200,17 +466,21 @@ Search run 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`
|
||||||
@@ -221,6 +491,8 @@ Search run notes:
|
|||||||
"query": "...",
|
"query": "...",
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null,
|
||||||
"requestId": "...",
|
"requestId": "...",
|
||||||
"latencyMs": 123,
|
"latencyMs": 123,
|
||||||
"error": null,
|
"error": null,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ This document defines the server-sent events (SSE) contract for chat completions
|
|||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
|
- `POST /v1/chats/:chatId/stream/attach`
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||||
- Request body is JSON
|
- Request body is JSON
|
||||||
|
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||||
@@ -18,20 +20,68 @@ Authentication:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai",
|
"persist": true,
|
||||||
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{ "role": "system|user|assistant|tool", "content": "string", "name": "optional" }
|
{
|
||||||
|
"role": "system|user|assistant|tool",
|
||||||
|
"content": "string",
|
||||||
|
"name": "optional",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"kind": "image",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "photo.jpg",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"sizeBytes": 12345,
|
||||||
|
"dataUrl": "data:image/jpeg;base64,..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "notes.md",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"sizeBytes": 4567,
|
||||||
|
"text": "# Notes\\n...",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If `chatId` is omitted, backend creates a new chat.
|
- `persist` defaults to `true`.
|
||||||
|
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- Backend stores only new non-assistant input history rows to avoid duplicates.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
|
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||||
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
|
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||||
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
|
## Attach Endpoint
|
||||||
|
|
||||||
|
`POST /v1/chats/:chatId/stream/attach`
|
||||||
|
- Body: none.
|
||||||
|
- Response uses the same `text/event-stream` transport and event names as `POST /v1/chat-completions/stream`.
|
||||||
|
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||||
|
- Returns `404 { "message": "active chat stream not found" }` if no stream is currently active for that chat.
|
||||||
|
- Authentication is the same as all other API endpoints.
|
||||||
|
|
||||||
|
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
@@ -41,18 +91,22 @@ 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
|
||||||
{
|
{
|
||||||
"type": "meta",
|
"type": "meta",
|
||||||
"chatId": "chat-id",
|
"chatId": "chat-id-or-null",
|
||||||
"callId": "llm-call-id",
|
"callId": "llm-call-id-or-null",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-4.1-mini"
|
"model": "gpt-4.1-mini"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||||
|
|
||||||
### `delta`
|
### `delta`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -63,6 +117,19 @@ Event order:
|
|||||||
|
|
||||||
### `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",
|
||||||
@@ -73,11 +140,12 @@ Event order:
|
|||||||
"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
|
||||||
@@ -102,30 +170,47 @@ Event order:
|
|||||||
|
|
||||||
## Provider Streaming Behavior
|
## Provider Streaming Behavior
|
||||||
|
|
||||||
- `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) 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`: same tool-enabled behavior as OpenAI.
|
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
|
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||||
|
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||||
|
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||||
|
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||||
|
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||||
|
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
|
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||||
|
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||||
|
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||||
|
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||||
|
|
||||||
Tool-enabled streaming notes (`openai`/`xai`):
|
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||||
- Stream may emit `tool_call` events while tool calls are executed.
|
- Stream may emit `tool_call` events while tool calls are executed.
|
||||||
- `delta` events stream incrementally as text is generated.
|
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||||
|
- OpenAI Responses stream events are normalized by the backend into this SSE contract; clients do not consume OpenAI's raw Responses stream event names.
|
||||||
|
|
||||||
## Persistence + Consistency Model
|
## Persistence + Consistency Model
|
||||||
|
|
||||||
Backend database remains source of truth.
|
Backend database remains source of truth.
|
||||||
|
|
||||||
During stream:
|
For persisted streams:
|
||||||
- Client may optimistically render accumulated `delta` text.
|
- Client may optimistically render accumulated `delta` text.
|
||||||
|
- 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 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.
|
||||||
- Backend then emits `done`.
|
- Backend then emits `done`.
|
||||||
|
|
||||||
On failure:
|
On persisted failure:
|
||||||
- Backend records call error and emits `error`.
|
- Backend records call error and emits `error`.
|
||||||
|
|
||||||
|
For `persist: false` streams:
|
||||||
|
- Client may render the same `meta`, `tool_call`, `delta`, and terminal events.
|
||||||
|
- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows.
|
||||||
|
- `done.text` is the canonical assistant text if the client later imports the result into a saved chat.
|
||||||
|
|
||||||
Client recommendation (for iOS/web):
|
Client recommendation (for iOS/web):
|
||||||
1. Render deltas in real time for UX.
|
1. Render deltas in real time for UX.
|
||||||
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.
|
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.
|
||||||
|
|||||||
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
|
FASTLANE_USER=you@example.com
|
||||||
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
|
||||||
|
# Optional App Store Connect API key settings for non-interactive upload and
|
||||||
|
# TestFlight build-number lookup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
11
ios/.gitignore
vendored
@@ -1,2 +1,11 @@
|
|||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
|
|||||||
@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- `just build` will:
|
- `just build` will:
|
||||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||||
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
||||||
|
- Preferred test command: `just test`
|
||||||
|
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
|
||||||
|
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
|
||||||
|
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
|
||||||
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
||||||
|
|
||||||
|
## Simulator Workflow
|
||||||
|
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
|
||||||
|
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
|
||||||
|
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
|
||||||
|
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||||
|
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
@@ -40,3 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- 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
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>net.buzzert.sybil2.quick-question</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Quick question</string>
|
||||||
|
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||||
|
<string>sparkles</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
@@ -5,9 +5,90 @@ 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()
|
||||||
}
|
}
|
||||||
|
.commands {
|
||||||
|
SybilCommands()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
targets:
|
targets:
|
||||||
SybilApp:
|
SybilApp:
|
||||||
type: application
|
type: application
|
||||||
platform: iOS
|
supportedDestinations:
|
||||||
|
- iOS
|
||||||
|
- macCatalyst
|
||||||
deploymentTarget: "18.0"
|
deploymentTarget: "18.0"
|
||||||
sources:
|
sources:
|
||||||
- Sources
|
- Sources
|
||||||
@@ -12,15 +14,18 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
|
PRODUCT_NAME: Sybil
|
||||||
PRODUCT_MODULE_NAME: SybilApp
|
PRODUCT_MODULE_NAME: SybilApp
|
||||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
SWIFT_VERSION: 6.0
|
SWIFT_VERSION: 6.0
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
|
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.1
|
MARKETING_VERSION: "1.10"
|
||||||
CURRENT_PROJECT_VERSION: 2
|
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
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane", "~> 2.227"
|
||||||
33
ios/Packages/Sybil/Package.resolved
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "a6321e2b291c1094ca66f749c90095f05aac7f8c6b4a6e54e0e77a1bb0e1a79f",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "networkimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||||
|
"version" : "0.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown-ui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swift-markdown-ui.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -2,7 +2,38 @@ 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
|
||||||
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
@State private var composerFocusRequest = 0
|
||||||
|
@State private var quickQuestionFocusRequest = 0
|
||||||
|
@State private var hasPendingQuickQuestionPresentation = false
|
||||||
|
@State private var isQuickQuestionPresented = false
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||||
|
|
||||||
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
|
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SybilKeyboardActions(
|
||||||
|
newChat: {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
},
|
||||||
|
newSearch: {
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
},
|
||||||
|
previousConversation: {
|
||||||
|
viewModel.selectPreviousSidebarItem()
|
||||||
|
},
|
||||||
|
nextConversation: {
|
||||||
|
viewModel.selectNextSidebarItem()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
SybilFontRegistry.registerIfNeeded()
|
SybilFontRegistry.registerIfNeeded()
|
||||||
@@ -24,19 +55,161 @@ public struct SplitView: View {
|
|||||||
} else if horizontalSizeClass == .compact {
|
} else if horizontalSizeClass == .compact {
|
||||||
SybilPhoneShellView(viewModel: viewModel)
|
SybilPhoneShellView(viewModel: viewModel)
|
||||||
} else {
|
} else {
|
||||||
NavigationSplitView {
|
GeometryReader { proxy in
|
||||||
SybilSidebarView(viewModel: viewModel)
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
} detail: {
|
SybilSidebarView(viewModel: viewModel)
|
||||||
SybilWorkspaceView(viewModel: viewModel)
|
} detail: {
|
||||||
|
SybilWorkspaceView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
composerFocusRequest: composerFocusRequest,
|
||||||
|
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
||||||
|
onShowSidebar: showSidebar,
|
||||||
|
onRequestNewChat: {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.navigationSplitViewStyle(.balanced)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
|
||||||
.tint(SybilTheme.primary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.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
|
||||||
|
switch nextPhase {
|
||||||
|
case .background:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
case .active:
|
||||||
|
viewModel.markAppActiveForNetwork()
|
||||||
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shouldRefreshOnForeground = false
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
|
refreshCollections: true,
|
||||||
|
refreshSelection: viewModel.hasRefreshableSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .inactive:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
||||||
|
return size.width < size.height ? .showSidebar : .hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSidebar() {
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) {
|
||||||
|
columnVisibility = .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueQuickQuestionPresentation() {
|
||||||
|
hasPendingQuickQuestionPresentation = true
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPendingQuickQuestionIfPossible() {
|
||||||
|
guard hasPendingQuickQuestionPresentation,
|
||||||
|
!viewModel.isCheckingSession,
|
||||||
|
viewModel.isAuthenticated
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
quickQuestionFocusRequest += 1
|
||||||
|
isQuickQuestionPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleQuickQuestionDismissed() {
|
||||||
|
viewModel.cancelQuickQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SybilCommands: Commands {
|
||||||
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some Commands {
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("New Chat") {
|
||||||
|
keyboardActions?.newChat()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
Button("New Search") {
|
||||||
|
keyboardActions?.newSearch()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandMenu("Conversation") {
|
||||||
|
Button("Previous Conversation") {
|
||||||
|
keyboardActions?.previousConversation()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("[", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
Button("Next Conversation") {
|
||||||
|
keyboardActions?.nextConversation()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("]", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SybilKeyboardActions {
|
||||||
|
var newChat: () -> Void
|
||||||
|
var newSearch: () -> Void
|
||||||
|
var previousConversation: () -> Void
|
||||||
|
var nextConversation: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilKeyboardActionsKey: FocusedValueKey {
|
||||||
|
typealias Value = SybilKeyboardActions
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FocusedValues {
|
||||||
|
var sybilKeyboardActions: SybilKeyboardActions? {
|
||||||
|
get { self[SybilKeyboardActionsKey.self] }
|
||||||
|
set { self[SybilKeyboardActionsKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor SybilAPIClient {
|
actor SybilAPIClient: SybilAPIClienting {
|
||||||
private let configuration: APIConfiguration
|
private let configuration: APIConfiguration
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
@@ -44,16 +44,26 @@ actor SybilAPIClient {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -116,6 +156,10 @@ actor SybilAPIClient {
|
|||||||
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
|
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -133,43 +177,35 @@ actor SybilAPIClient {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "meta":
|
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
|
||||||
await onEvent(.meta(payload))
|
|
||||||
case "tool_call":
|
|
||||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
|
||||||
await onEvent(.toolCall(payload))
|
|
||||||
case "delta":
|
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
|
||||||
await onEvent(.delta(payload))
|
|
||||||
case "done":
|
|
||||||
do {
|
|
||||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
} catch {
|
|
||||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
|
||||||
SybilLog.warning(
|
|
||||||
SybilLog.network,
|
|
||||||
"Recovered chat stream done payload from concatenated SSE data"
|
|
||||||
)
|
|
||||||
await onEvent(.done(recovered))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/chats/\(chatID)/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Attached chat stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -188,34 +224,35 @@ actor SybilAPIClient {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "search_results":
|
|
||||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchResults(payload))
|
|
||||||
case "search_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchError(payload))
|
|
||||||
case "answer":
|
|
||||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answer(payload))
|
|
||||||
case "answer_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answerError(payload))
|
|
||||||
case "done":
|
|
||||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Search stream completed")
|
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/searches/\(searchID)/run/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Attached search stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
private func request<Response: Decodable>(
|
private func request<Response: Decodable>(
|
||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
@@ -498,6 +535,75 @@ actor SybilAPIClient {
|
|||||||
return try? Self.decodeJSON(type, from: data)
|
return try? Self.decodeJSON(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func handleCompletionStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "meta":
|
||||||
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
|
await onEvent(.meta(payload))
|
||||||
|
case "tool_call":
|
||||||
|
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||||
|
await onEvent(.toolCall(payload))
|
||||||
|
case "delta":
|
||||||
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
|
await onEvent(.delta(payload))
|
||||||
|
case "done":
|
||||||
|
do {
|
||||||
|
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
} catch {
|
||||||
|
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"Recovered chat stream done payload from concatenated SSE data"
|
||||||
|
)
|
||||||
|
await onEvent(.done(recovered))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleSearchStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "search_results":
|
||||||
|
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchResults(payload))
|
||||||
|
case "search_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchError(payload))
|
||||||
|
case "answer":
|
||||||
|
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answer(payload))
|
||||||
|
case "answer_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answerError(payload))
|
||||||
|
case "done":
|
||||||
|
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func flushSSEEvent(
|
private static func flushSSEEvent(
|
||||||
eventName: inout String,
|
eventName: inout String,
|
||||||
dataLines: inout [String]
|
dataLines: inout [String]
|
||||||
@@ -551,13 +657,26 @@ actor SybilAPIClient {
|
|||||||
|
|
||||||
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 {
|
||||||
|
|||||||
49
ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol SybilAPIClienting: Sendable {
|
||||||
|
func verifySession() async throws -> AuthSession
|
||||||
|
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||||
|
func listChats() async throws -> [ChatSummary]
|
||||||
|
func createChat(
|
||||||
|
title: String?,
|
||||||
|
provider: Provider?,
|
||||||
|
model: String?,
|
||||||
|
messages: [CompletionRequestMessage]?
|
||||||
|
) async throws -> ChatSummary
|
||||||
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||||
|
func deleteChat(chatID: String) async throws
|
||||||
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
|
func listSearches() async throws -> [SearchSummary]
|
||||||
|
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||||
|
func getSearch(searchID: String) async throws -> SearchDetail
|
||||||
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
|
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
||||||
|
func deleteSearch(searchID: String) async throws
|
||||||
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||||
|
func runCompletionStream(
|
||||||
|
body: CompletionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
func runSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
body: SearchRunRequest,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SybilAPIClienting {
|
||||||
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
|
try await createChat(title: title, provider: nil, model: nil, messages: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SybilAttachmentTone {
|
||||||
|
case composer
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilAttachmentListView: View {
|
||||||
|
var attachments: [ChatAttachment]
|
||||||
|
var tone: SybilAttachmentTone
|
||||||
|
var onRemove: ((String) -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(attachments) { attachment in
|
||||||
|
Group {
|
||||||
|
if attachment.kind == .image {
|
||||||
|
imageCard(attachment)
|
||||||
|
} else {
|
||||||
|
textCard(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func imageCard(_ attachment: ChatAttachment) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if let image = SybilChatAttachmentSupport.image(for: attachment) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.black.opacity(0.18))
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 22, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(titleColor.opacity(0.92))
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(attachment.filename)
|
||||||
|
.font(.sybil(.footnote, weight: .medium))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(attachment.mimeType)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let onRemove {
|
||||||
|
removeButton(for: attachment.id, onRemove: onRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.background(cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func textCard(_ attachment: ChatAttachment) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(titleColor.opacity(0.92))
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(attachment.filename)
|
||||||
|
.font(.sybil(.footnote, weight: .medium))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
attachment.truncated == true
|
||||||
|
? "\(attachment.mimeType) • truncated"
|
||||||
|
: attachment.mimeType
|
||||||
|
)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let onRemove {
|
||||||
|
removeButton(for: attachment.id, onRemove: onRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(SybilChatAttachmentSupport.previewText(for: attachment))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(bodyColor)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.black.opacity(0.16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color.white.opacity(0.05), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeButton(for attachmentID: String, onRemove: @escaping (String) -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onRemove(attachmentID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Remove attachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardBackground: some ShapeStyle {
|
||||||
|
switch tone {
|
||||||
|
case .composer:
|
||||||
|
return AnyShapeStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surfaceStrong.opacity(0.78)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .user:
|
||||||
|
return AnyShapeStyle(Color.black.opacity(0.14))
|
||||||
|
case .assistant:
|
||||||
|
return AnyShapeStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [SybilTheme.surface.opacity(0.58), SybilTheme.surfaceStrong.opacity(0.42)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardBorder: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer:
|
||||||
|
return SybilTheme.border.opacity(0.82)
|
||||||
|
case .user:
|
||||||
|
return Color.white.opacity(0.12)
|
||||||
|
case .assistant:
|
||||||
|
return SybilTheme.border.opacity(0.58)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleColor: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer, .assistant:
|
||||||
|
return SybilTheme.text
|
||||||
|
case .user:
|
||||||
|
return SybilTheme.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bodyColor: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer, .assistant:
|
||||||
|
return SybilTheme.text.opacity(0.94)
|
||||||
|
case .user:
|
||||||
|
return SybilTheme.text.opacity(0.96)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilBackgroundTaskAssertion {
|
||||||
|
private let name: String
|
||||||
|
private var identifier: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
init?(name: String, onExpiration: @escaping @MainActor () -> Void = {}) {
|
||||||
|
self.name = name
|
||||||
|
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
SybilLog.warning(SybilLog.app, "Background task expired: \(self.name)")
|
||||||
|
onExpiration()
|
||||||
|
self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard identifier != .invalid else {
|
||||||
|
SybilLog.warning(SybilLog.app, "Failed to acquire background task: \(name)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.debug(SybilLog.app, "Acquired background task: \(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard identifier != .invalid else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UIApplication.shared.endBackgroundTask(identifier)
|
||||||
|
identifier = .invalid
|
||||||
|
SybilLog.debug(SybilLog.app, "Ended background task: \(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum ChatAttachmentError: LocalizedError {
|
||||||
|
case unsupportedType(String)
|
||||||
|
case imageTooLarge(String)
|
||||||
|
case textTooLarge(String)
|
||||||
|
case unreadableFile(String)
|
||||||
|
case unsupportedImageFormat(String)
|
||||||
|
case tooManyAttachments(Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .unsupportedType(filename):
|
||||||
|
return "Unsupported file type for '\(filename)'. Use PNG/JPEG images or text-based files."
|
||||||
|
case let .imageTooLarge(filename):
|
||||||
|
return "Image '\(filename)' exceeds the 6 MB upload limit."
|
||||||
|
case let .textTooLarge(filename):
|
||||||
|
return "Text file '\(filename)' exceeds the 8 MB upload limit."
|
||||||
|
case let .unreadableFile(filename):
|
||||||
|
return "Could not read '\(filename)'."
|
||||||
|
case let .unsupportedImageFormat(filename):
|
||||||
|
return "Image '\(filename)' could not be converted to PNG or JPEG."
|
||||||
|
case let .tooManyAttachments(limit):
|
||||||
|
return "You can attach up to \(limit) files per message."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SybilChatAttachmentSupport {
|
||||||
|
static let maxAttachmentsPerMessage = 8
|
||||||
|
static let maxImageBytes = 6 * 1024 * 1024
|
||||||
|
static let maxTextBytes = 8 * 1024 * 1024
|
||||||
|
static let maxTextCharacters = 200_000
|
||||||
|
|
||||||
|
private static let supportedTextExtensions: Set<String> = [
|
||||||
|
"txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "xml", "yaml", "yml", "html", "htm",
|
||||||
|
"css", "js", "jsx", "ts", "tsx", "py", "rb", "java", "c", "cc", "cpp", "h", "hpp", "go",
|
||||||
|
"rs", "sh", "sql", "log", "toml", "ini", "cfg", "conf", "swift", "kt", "m", "mm"
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let supportedTextMimeTypes: Set<String> = [
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/sql",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-httpd-php",
|
||||||
|
"application/x-javascript",
|
||||||
|
"application/x-sh",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"image/svg+xml"
|
||||||
|
]
|
||||||
|
|
||||||
|
static func attachmentSummary(_ attachments: [ChatAttachment]) -> String {
|
||||||
|
guard !attachments.isEmpty else { return "" }
|
||||||
|
let names = attachments.map(\.filename).joined(separator: ", ")
|
||||||
|
return attachments.count == 1 ? names : "Attached: \(names)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func metadataValue(for attachments: [ChatAttachment]) -> JSONValue? {
|
||||||
|
guard !attachments.isEmpty else { return nil }
|
||||||
|
return .object([
|
||||||
|
"attachments": .array(attachments.map(\.jsonValue))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildAttachments(from urls: [URL]) throws -> [ChatAttachment] {
|
||||||
|
try urls.map { try buildAttachment(fromFileURL: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildImageAttachment(image: UIImage, filename: String = "pasted-image.jpg") throws -> ChatAttachment {
|
||||||
|
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
|
||||||
|
return try buildImageAttachment(data: pngData, filename: filename, contentType: .png)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let jpegData = image.jpegData(compressionQuality: 0.92) else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try buildImageAttachment(data: jpegData, filename: filename, contentType: .jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildTextAttachment(text: String, filename: String = "pasted-text.txt", mimeType: String = "text/plain") throws -> ChatAttachment {
|
||||||
|
let data = Data(text.utf8)
|
||||||
|
return try buildTextAttachment(data: data, filename: filename, mimeType: mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func buildAttachments(from itemProviders: [NSItemProvider]) async throws -> [ChatAttachment] {
|
||||||
|
var attachments: [ChatAttachment] = []
|
||||||
|
|
||||||
|
for provider in itemProviders {
|
||||||
|
if let fileURL = try await loadFileURL(from: provider) {
|
||||||
|
attachments.append(try buildAttachment(fromFileURL: fileURL))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||||
|
if let attachment = try await loadImageAttachment(from: provider) {
|
||||||
|
attachments.append(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previewText(for attachment: ChatAttachment) -> String {
|
||||||
|
let normalized = (attachment.text ?? "")
|
||||||
|
.replacingOccurrences(of: "\r", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if normalized.isEmpty {
|
||||||
|
return "(empty file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.count <= 280 {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = normalized.index(normalized.startIndex, offsetBy: 280)
|
||||||
|
return normalized[..<endIndex].trimmingCharacters(in: .whitespacesAndNewlines) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
static func image(for attachment: ChatAttachment) -> UIImage? {
|
||||||
|
guard attachment.kind == .image,
|
||||||
|
let dataURL = attachment.dataUrl,
|
||||||
|
let data = decodeDataURL(dataURL)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildAttachment(fromFileURL url: URL) throws -> ChatAttachment {
|
||||||
|
let accessed = url.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessed {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
||||||
|
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey])
|
||||||
|
let contentType = resourceValues?.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
throw ChatAttachmentError.unreadableFile(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType?.conforms(to: .image) == true {
|
||||||
|
return try buildImageAttachment(data: data, filename: filename, contentType: contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTextLike(contentType: contentType, mimeType: contentType?.preferredMIMEType, filename: filename) {
|
||||||
|
return try buildTextAttachment(data: data, filename: filename, mimeType: contentType?.preferredMIMEType ?? "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ChatAttachmentError.unsupportedType(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildImageAttachment(data: Data, filename: String, contentType: UTType?) throws -> ChatAttachment {
|
||||||
|
var mimeType = contentType?.preferredMIMEType
|
||||||
|
var payload = data
|
||||||
|
|
||||||
|
if mimeType != "image/png" && mimeType != "image/jpeg" {
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
|
||||||
|
payload = pngData
|
||||||
|
mimeType = "image/png"
|
||||||
|
} else if let jpegData = image.jpegData(compressionQuality: 0.92) {
|
||||||
|
payload = jpegData
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
} else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.count > maxImageBytes {
|
||||||
|
throw ChatAttachmentError.imageTooLarge(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedMimeType = (mimeType == "image/png") ? "image/png" : "image/jpeg"
|
||||||
|
let dataUrl = "data:\(normalizedMimeType);base64,\(payload.base64EncodedString())"
|
||||||
|
|
||||||
|
return .image(
|
||||||
|
filename: filename,
|
||||||
|
mimeType: normalizedMimeType,
|
||||||
|
sizeBytes: payload.count,
|
||||||
|
dataUrl: dataUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildTextAttachment(data: Data, filename: String, mimeType: String) throws -> ChatAttachment {
|
||||||
|
if data.count > maxTextBytes {
|
||||||
|
throw ChatAttachmentError.textTooLarge(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String(decoding: data, as: UTF8.self)
|
||||||
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\u{0000}", with: "")
|
||||||
|
|
||||||
|
let truncated = normalized.count > maxTextCharacters
|
||||||
|
let trimmedText: String
|
||||||
|
if truncated {
|
||||||
|
let endIndex = normalized.index(normalized.startIndex, offsetBy: maxTextCharacters)
|
||||||
|
trimmedText = String(normalized[..<endIndex])
|
||||||
|
} else {
|
||||||
|
trimmedText = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return .text(
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: data.count,
|
||||||
|
text: trimmedText,
|
||||||
|
truncated: truncated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isTextLike(contentType: UTType?, mimeType: String?, filename: String) -> Bool {
|
||||||
|
if let contentType {
|
||||||
|
if contentType.conforms(to: .text) || contentType.conforms(to: .plainText) || contentType.conforms(to: .sourceCode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contentType.conforms(to: .json) || contentType.conforms(to: .xml) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mimeType {
|
||||||
|
if mimeType.hasPrefix("text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if supportedTextMimeTypes.contains(mimeType.lowercased()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = URL(fileURLWithPath: filename).pathExtension.lowercased()
|
||||||
|
return supportedTextExtensions.contains(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeDataURL(_ value: String) -> Data? {
|
||||||
|
guard let separator = value.firstIndex(of: ",") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = value[value.index(after: separator)...]
|
||||||
|
return Data(base64Encoded: String(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadFileURL(from provider: NSItemProvider) async throws -> URL? {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = item as? URL {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = item as? Data,
|
||||||
|
let url = URL(dataRepresentation: data, relativeTo: nil) {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let string = item as? String,
|
||||||
|
let url = URL(string: string) {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadImageAttachment(from provider: NSItemProvider) async throws -> ChatAttachment? {
|
||||||
|
let preferredImageType: UTType = if provider.hasItemConformingToTypeIdentifier(UTType.png.identifier) {
|
||||||
|
.png
|
||||||
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.jpeg.identifier) {
|
||||||
|
.jpeg
|
||||||
|
} else {
|
||||||
|
.image
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = try await loadDataRepresentation(from: provider, type: preferredImageType) {
|
||||||
|
let filenameExtension = preferredImageType.preferredFilenameExtension ?? "jpg"
|
||||||
|
let filename = "pasted-image.\(filenameExtension)"
|
||||||
|
return try buildImageAttachment(data: data, filename: filename, contentType: preferredImageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let image = try await loadUIImage(from: provider),
|
||||||
|
let jpegData = image.jpegData(compressionQuality: 0.92) {
|
||||||
|
return try buildImageAttachment(data: jpegData, filename: "pasted-image.jpg", contentType: .jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadDataRepresentation(from provider: NSItemProvider, type: UTType) async throws -> Data? {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(type.identifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadUIImage(from provider: NSItemProvider) async throws -> UIImage? {
|
||||||
|
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: object as? UIImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,26 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var topContentInset: CGFloat = 0
|
||||||
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
@State private var hasTrackedToolCallMessages = false
|
||||||
messages.contains { message in
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
|
private var renderItems: [TranscriptRenderItem] {
|
||||||
|
buildTranscriptRenderItems(from: messages)
|
||||||
|
}
|
||||||
|
private var toolCallMessageIDs: Set<String> {
|
||||||
|
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
|
||||||
|
}
|
||||||
|
private var enteringToolCallMessageIDs: Set<String> {
|
||||||
|
guard hasTrackedToolCallMessages else { return [] }
|
||||||
|
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
|
||||||
|
}
|
||||||
|
private var toolCallMessageIDSignature: String {
|
||||||
|
toolCallMessageIDs.sorted().joined(separator: "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -23,49 +38,103 @@ struct SybilChatTranscriptView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(messages) { message in
|
ForEach(renderItems) { item in
|
||||||
MessageBubble(message: message, isSending: isSending)
|
switch item {
|
||||||
.frame(maxWidth: .infinity)
|
case let .message(message):
|
||||||
.id(message.id)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
}
|
.frame(maxWidth: .infinity)
|
||||||
|
case let .toolGroup(id, messages):
|
||||||
if isSending && !hasPendingAssistant {
|
ToolCallStackView(
|
||||||
HStack(spacing: 8) {
|
groupID: id,
|
||||||
ProgressView()
|
messages: messages,
|
||||||
.controlSize(.small)
|
entryAnimationIDs: enteringToolCallMessageIDs
|
||||||
.tint(SybilTheme.textMuted)
|
)
|
||||||
Text("Assistant is typing…")
|
.frame(maxWidth: .infinity)
|
||||||
.font(.sybil(.footnote))
|
.id(id)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
}
|
||||||
.id("typing-indicator")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: 2)
|
.frame(height: 18 + bottomContentInset)
|
||||||
.id("chat-bottom-anchor")
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 18)
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
syncKnownToolCallMessageIDs()
|
||||||
|
scrollToBottom(with: proxy, animated: false)
|
||||||
}
|
}
|
||||||
.onChange(of: messages.map(\.id)) { _, _ in
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
syncKnownToolCallMessageIDs()
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: isSending) { _, _ in
|
.onChange(of: bottomPinRequestID) { _, _ in
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
scrollToBottom(with: proxy, animated: true)
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
let action = {
|
||||||
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.18), action)
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncKnownToolCallMessageIDs() {
|
||||||
|
guard !toolCallMessageIDs.isEmpty else { return }
|
||||||
|
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
||||||
|
hasTrackedToolCallMessages = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranscriptRenderItem: Identifiable {
|
||||||
|
case message(Message)
|
||||||
|
case toolGroup(id: String, messages: [Message])
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case let .message(message):
|
||||||
|
return message.id
|
||||||
|
case let .toolGroup(id, _):
|
||||||
|
return "tool-group-\(id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
|
||||||
|
var items: [TranscriptRenderItem] = []
|
||||||
|
var toolRun: [Message] = []
|
||||||
|
|
||||||
|
func flushToolRun() {
|
||||||
|
guard !toolRun.isEmpty else { return }
|
||||||
|
if toolRun.count == 1, let message = toolRun.first {
|
||||||
|
items.append(.message(message))
|
||||||
|
} else if let first = toolRun.first {
|
||||||
|
items.append(.toolGroup(id: first.id, messages: toolRun))
|
||||||
|
}
|
||||||
|
toolRun.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
if message.toolCallMetadata != nil {
|
||||||
|
toolRun.append(message)
|
||||||
|
} else {
|
||||||
|
flushToolRun()
|
||||||
|
items.append(.message(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushToolRun()
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MessageBubble: View {
|
private struct MessageBubble: View {
|
||||||
@@ -98,6 +167,13 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !message.attachments.isEmpty {
|
||||||
|
SybilAttachmentListView(
|
||||||
|
attachments: message.attachments,
|
||||||
|
tone: isUser ? .user : .assistant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if isPendingAssistant {
|
if isPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -108,7 +184,7 @@ private struct MessageBubble: View {
|
|||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
} else {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
@@ -117,6 +193,7 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.vertical, isUser ? 13 : 2)
|
||||||
|
.textSelection(.enabled)
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
if isUser {
|
if isUser {
|
||||||
@@ -155,10 +232,225 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ToolCallStackView: View {
|
||||||
|
private struct CardLayout {
|
||||||
|
var x: CGFloat
|
||||||
|
var y: CGFloat
|
||||||
|
var scale: CGFloat
|
||||||
|
var opacity: Double
|
||||||
|
var zIndex: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupID: String
|
||||||
|
var messages: [Message]
|
||||||
|
var entryAnimationIDs: Set<String>
|
||||||
|
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
private let visibleCollapsedLimit = 4
|
||||||
|
private let cardHeight: CGFloat = 62
|
||||||
|
private let expandedGap: CGFloat = 10
|
||||||
|
private let collapsedStepX: CGFloat = 11
|
||||||
|
private let collapsedStepY: CGFloat = 10
|
||||||
|
private let toggleSize: CGFloat = 32
|
||||||
|
private let toggleGap: CGFloat = 12
|
||||||
|
|
||||||
|
private var animation: Animation? {
|
||||||
|
reduceMotion ? nil : .easeInOut(duration: 0.34)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleCollapsedCount: Int {
|
||||||
|
min(messages.count, visibleCollapsedLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenCount: Int {
|
||||||
|
max(0, messages.count - visibleCollapsedLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var containerHeight: CGFloat {
|
||||||
|
if isExpanded {
|
||||||
|
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
|
||||||
|
}
|
||||||
|
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityLabel: String {
|
||||||
|
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
|
||||||
|
let toggleX = cardWidth + toggleGap
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
||||||
|
let layout = layout(for: index)
|
||||||
|
let depth = messages.count - index - 1
|
||||||
|
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
||||||
|
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
||||||
|
|
||||||
|
ToolCallStackCard(
|
||||||
|
message: message,
|
||||||
|
cardHeight: cardHeight,
|
||||||
|
compactLayout: true,
|
||||||
|
animateEntry: shouldAnimateEntry
|
||||||
|
)
|
||||||
|
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
||||||
|
.scaleEffect(layout.scale, anchor: .topLeading)
|
||||||
|
.opacity(layout.opacity)
|
||||||
|
.offset(x: layout.x, y: layout.y)
|
||||||
|
.zIndex(layout.zIndex)
|
||||||
|
.allowsHitTesting(!isHidden)
|
||||||
|
.accessibilityHidden(isHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isExpanded && hiddenCount > 0 {
|
||||||
|
Text("+\(hiddenCount)")
|
||||||
|
.font(.sybil(.caption2, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.black.opacity(0.58))
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation(animation) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||||
|
.frame(width: toggleSize, height: toggleSize)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
|
||||||
|
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
|
||||||
|
.offset(x: toggleX, y: 8)
|
||||||
|
.zIndex(Double(messages.count + 2))
|
||||||
|
}
|
||||||
|
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
|
||||||
|
.animation(animation, value: isExpanded)
|
||||||
|
}
|
||||||
|
.frame(height: containerHeight)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layout(for index: Int) -> CardLayout {
|
||||||
|
if isExpanded {
|
||||||
|
return CardLayout(
|
||||||
|
x: 0,
|
||||||
|
y: CGFloat(index) * (cardHeight + expandedGap),
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: Double(messages.count - index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = messages.count - index - 1
|
||||||
|
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
|
||||||
|
let isHidden = depth >= visibleCollapsedLimit
|
||||||
|
return CardLayout(
|
||||||
|
x: CGFloat(visibleDepth) * collapsedStepX,
|
||||||
|
y: CGFloat(visibleDepth) * collapsedStepY,
|
||||||
|
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
|
||||||
|
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
|
||||||
|
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToolCallStackCard: View {
|
||||||
|
var message: Message
|
||||||
|
var cardHeight: CGFloat
|
||||||
|
var compactLayout: Bool
|
||||||
|
var animateEntry: Bool
|
||||||
|
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var entryAnimationArmed = false
|
||||||
|
@State private var didEnter = false
|
||||||
|
|
||||||
|
private var isPreparingEntry: Bool {
|
||||||
|
(animateEntry || entryAnimationArmed) && !didEnter
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let metadata = message.toolCallMetadata {
|
||||||
|
ToolCallActivityChip(
|
||||||
|
metadata: metadata,
|
||||||
|
fallbackContent: message.content,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
compactLayout: compactLayout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: cardHeight, alignment: .top)
|
||||||
|
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
|
||||||
|
.offset(y: isPreparingEntry ? -8 : 0)
|
||||||
|
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||||
|
.opacity(isPreparingEntry ? 0.72 : 1)
|
||||||
|
.onAppear {
|
||||||
|
guard !didEnter, !entryAnimationArmed else { return }
|
||||||
|
guard animateEntry else {
|
||||||
|
didEnter = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entryAnimationArmed = true
|
||||||
|
if reduceMotion {
|
||||||
|
didEnter = true
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
||||||
|
didEnter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ToolCallActivityChip: View {
|
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
|
||||||
|
var compactLayout: Bool = false
|
||||||
|
|
||||||
private var summary: String {
|
private var summary: String {
|
||||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||||
@@ -202,11 +494,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")
|
||||||
}
|
}
|
||||||
@@ -218,14 +521,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)
|
||||||
|
|
||||||
@@ -234,12 +537,14 @@ private struct ToolCallActivityChip: View {
|
|||||||
.font(.sybil(.subheadline))
|
.font(.sybil(.subheadline))
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||||
.lineSpacing(3)
|
.lineSpacing(3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.lineLimit(compactLayout ? 1 : nil)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.fixedSize(horizontal: false, vertical: !compactLayout)
|
||||||
|
|
||||||
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)
|
||||||
@@ -249,16 +554,50 @@ private struct ToolCallActivityChip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ extension Theme {
|
|||||||
.paragraph { configuration in
|
.paragraph { configuration in
|
||||||
configuration.label
|
configuration.label
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.relativeLineSpacing(.em(0.36))
|
.relativeLineSpacing(.em(0.46))
|
||||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||||
}
|
}
|
||||||
.blockquote { configuration in
|
.blockquote { configuration in
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,11 +23,139 @@ public enum MessageRole: String, Codable, Hashable, Sendable {
|
|||||||
case tool
|
case tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public enum Kind: String, Codable, Hashable, Sendable {
|
||||||
|
case image
|
||||||
|
case text
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
public var kind: Kind
|
||||||
|
public var filename: String
|
||||||
|
public var mimeType: String
|
||||||
|
public var sizeBytes: Int
|
||||||
|
public var dataUrl: String?
|
||||||
|
public var text: String?
|
||||||
|
public var truncated: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
kind: Kind,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
dataUrl: String? = nil,
|
||||||
|
text: String? = nil,
|
||||||
|
truncated: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.sizeBytes = sizeBytes
|
||||||
|
self.dataUrl = dataUrl
|
||||||
|
self.text = text
|
||||||
|
self.truncated = truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func image(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
dataUrl: String
|
||||||
|
) -> ChatAttachment {
|
||||||
|
ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: .image,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
dataUrl: dataUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func text(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
text: String,
|
||||||
|
truncated: Bool
|
||||||
|
) -> ChatAttachment {
|
||||||
|
ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: .text,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
text: text,
|
||||||
|
truncated: truncated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonValue: JSONValue {
|
||||||
|
var object: [String: JSONValue] = [
|
||||||
|
"kind": .string(kind.rawValue),
|
||||||
|
"id": .string(id),
|
||||||
|
"filename": .string(filename),
|
||||||
|
"mimeType": .string(mimeType),
|
||||||
|
"sizeBytes": .number(Double(sizeBytes))
|
||||||
|
]
|
||||||
|
|
||||||
|
if let dataUrl {
|
||||||
|
object["dataUrl"] = .string(dataUrl)
|
||||||
|
}
|
||||||
|
if let text {
|
||||||
|
object["text"] = .string(text)
|
||||||
|
}
|
||||||
|
if let truncated {
|
||||||
|
object["truncated"] = .bool(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .object(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func attachments(from metadata: JSONValue?) -> [ChatAttachment] {
|
||||||
|
guard let metadataObject = metadata?.objectValue,
|
||||||
|
let values = metadataObject["attachments"]?.arrayValue
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.compactMap { value in
|
||||||
|
guard let object = value.objectValue,
|
||||||
|
let kindRaw = object["kind"]?.stringValue,
|
||||||
|
let kind = Kind(rawValue: kindRaw),
|
||||||
|
let id = object["id"]?.stringValue,
|
||||||
|
let filename = object["filename"]?.stringValue,
|
||||||
|
let mimeType = object["mimeType"]?.stringValue,
|
||||||
|
let sizeNumber = object["sizeBytes"]?.numberValue
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: Int(sizeNumber),
|
||||||
|
dataUrl: object["dataUrl"]?.stringValue,
|
||||||
|
text: object["text"]?.stringValue,
|
||||||
|
truncated: object["truncated"]?.boolValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var 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?
|
||||||
@@ -38,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 {
|
||||||
@@ -48,6 +259,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var name: String?
|
public var name: String?
|
||||||
public var metadata: JSONValue? = nil
|
public var metadata: JSONValue? = nil
|
||||||
|
|
||||||
|
public var attachments: [ChatAttachment] {
|
||||||
|
ChatAttachment.attachments(from: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
public var toolCallMetadata: ToolCallMetadata? {
|
public var toolCallMetadata: ToolCallMetadata? {
|
||||||
guard role == .tool,
|
guard role == .tool,
|
||||||
let object = metadata?.objectValue,
|
let object = metadata?.objectValue,
|
||||||
@@ -155,6 +370,20 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var arrayValue: [JSONValue]? {
|
||||||
|
if case let .array(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var boolValue: Bool? {
|
||||||
|
if case let .bool(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
@@ -162,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?
|
||||||
@@ -200,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?
|
||||||
@@ -210,6 +443,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var results: [SearchResultItem]
|
public var results: [SearchResultItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
|
||||||
|
public var chats: [String]
|
||||||
|
public var searches: [String]
|
||||||
|
|
||||||
|
public init(chats: [String] = [], searches: [String] = []) {
|
||||||
|
self.chats = chats
|
||||||
|
self.searches = searches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SearchRunRequest: Codable, Sendable {
|
public struct SearchRunRequest: Codable, Sendable {
|
||||||
public var query: String?
|
public var query: String?
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -239,17 +482,19 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
|||||||
public var role: MessageRole
|
public var role: MessageRole
|
||||||
public var content: String
|
public var content: String
|
||||||
public var name: String?
|
public var name: String?
|
||||||
|
public var attachments: [ChatAttachment]?
|
||||||
|
|
||||||
public init(role: MessageRole, content: String, name: String? = nil) {
|
public init(role: MessageRole, content: String, name: String? = nil, attachments: [ChatAttachment]? = nil) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.content = content
|
self.content = content
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.attachments = attachments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -269,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?
|
||||||
}
|
}
|
||||||
@@ -366,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,29 +22,473 @@ enum PhoneRoute: Hashable {
|
|||||||
|
|
||||||
struct SybilPhoneShellView: View {
|
struct SybilPhoneShellView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@State private var path: [PhoneRoute] = []
|
@State private var route: PhoneRoute = .draftChat
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
@State private var composerFocusRequest = 0
|
||||||
|
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
||||||
|
@State private var isSidebarOverlayPresented = false
|
||||||
|
@State private var sidebarSwipeOffset: CGFloat = 0
|
||||||
|
@State private var sidebarSwipeIsActive = false
|
||||||
|
@State private var sidebarSwipeIsCompleting = false
|
||||||
|
@State private var sidebarSwipeHasLatched = false
|
||||||
|
@State private var sidebarHighlightSelection: SidebarSelection?
|
||||||
|
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||||
|
@State private var openingSelectionRequestID: UUID?
|
||||||
|
|
||||||
|
private var canRecognizeSidebarSwipe: Bool {
|
||||||
|
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarOverlayProgress: CGFloat {
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return SidebarOverlaySwipeMetrics.progress(
|
||||||
|
for: sidebarSwipeOffset,
|
||||||
|
width: phoneStackWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldRenderSidebarOverlay: Bool {
|
||||||
|
isSidebarOverlayPresented ||
|
||||||
|
sidebarSwipeIsActive ||
|
||||||
|
sidebarSwipeIsCompleting ||
|
||||||
|
sidebarOverlayProgress > 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentRouteSelection: SidebarSelection? {
|
||||||
|
switch route {
|
||||||
|
case let .chat(chatID):
|
||||||
|
return .chat(chatID)
|
||||||
|
case let .search(searchID):
|
||||||
|
return .search(searchID)
|
||||||
|
case .draftChat, .draftSearch, .settings:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var highlightedSidebarSelection: SidebarSelection? {
|
||||||
|
sidebarHighlightSelection ?? currentRouteSelection
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
GeometryReader { proxy in
|
||||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
phoneStack(width: proxy.size.width)
|
||||||
.navigationTitle("")
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.onAppear {
|
||||||
.toolbar {
|
updatePhoneStackWidth(proxy.size.width)
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
}
|
||||||
SybilWordmark(size: 18)
|
.onChange(of: proxy.size.width) { _, width in
|
||||||
}
|
updatePhoneStackWidth(width)
|
||||||
}
|
}
|
||||||
.navigationDestination(for: PhoneRoute.self) { route in
|
|
||||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
|
.animation(.easeOut(duration: 0.22), value: route)
|
||||||
|
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||||
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
|
switch nextPhase {
|
||||||
|
case .background:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
case .active:
|
||||||
|
viewModel.markAppActiveForNetwork()
|
||||||
|
guard shouldRefreshOnForeground else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shouldRefreshOnForeground = false
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
|
refreshCollections: isSidebarOverlayPresented,
|
||||||
|
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .inactive:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phoneStack(width: CGFloat) -> some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
phoneWorkspaceLayer
|
||||||
|
.zIndex(0)
|
||||||
|
|
||||||
|
phoneSidebarOverlayLayer(width: width)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phoneWorkspaceLayer: some View {
|
||||||
|
SybilPhoneDestinationView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
composerFocusRequest: $composerFocusRequest,
|
||||||
|
route: route,
|
||||||
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
|
onShowSidebar: showSidebarOverlay
|
||||||
|
)
|
||||||
|
.background(SybilTheme.background)
|
||||||
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
|
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
|
||||||
|
.allowsHitTesting(!shouldRenderSidebarOverlay)
|
||||||
|
.background {
|
||||||
|
sidebarSwipeInstaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
phoneOverlayTopBar
|
||||||
|
|
||||||
|
SybilPhoneSidebarRoot(
|
||||||
|
viewModel: viewModel,
|
||||||
|
highlightedSelection: highlightedSidebarSelection,
|
||||||
|
onSelect: openSidebarSelection,
|
||||||
|
onRoute: showRouteAndClearSidebarHighlight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.opacity(sidebarOverlayProgress)
|
||||||
|
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
|
||||||
|
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
|
||||||
|
.allowsHitTesting(isSidebarOverlayPresented)
|
||||||
|
.accessibilityHidden(!isSidebarOverlayPresented)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarSwipeInstaller: some View {
|
||||||
|
WorkspaceSwipePanInstaller(
|
||||||
|
direction: .right,
|
||||||
|
isEnabled: canRecognizeSidebarSwipe,
|
||||||
|
onBegan: { width in
|
||||||
|
beginSidebarSwipe(containerWidth: width)
|
||||||
|
},
|
||||||
|
onChanged: { translationX, width in
|
||||||
|
updateSidebarSwipe(with: translationX, containerWidth: width)
|
||||||
|
},
|
||||||
|
onEnded: { translationX, width, velocityX, didFinish in
|
||||||
|
finishSidebarSwipe(
|
||||||
|
translationX: translationX,
|
||||||
|
containerWidth: width,
|
||||||
|
velocityX: velocityX,
|
||||||
|
didFinish: didFinish
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
|
||||||
|
guard !isSidebarOverlayPresented else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startNewChatFromDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phoneOverlayTopBar: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
SybilWordmark(size: 21)
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
hideSidebarOverlay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right.2")
|
||||||
|
.font(.system(size: 21, weight: .bold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.frame(width: 54, height: 54)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.fill(SybilTheme.surface.opacity(0.76))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Hide conversations")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background {
|
||||||
|
SybilPhoneOverlayBlurBand(edge: .top)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePhoneStackWidth(_ width: CGFloat) {
|
||||||
|
phoneStackWidth = max(width, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNewChatFromDestination() {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
showRoute(.draftChat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||||
|
let update = {
|
||||||
|
route = nextRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
update()
|
||||||
|
isSidebarOverlayPresented = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
||||||
|
showRoute(nextRoute)
|
||||||
|
clearSidebarHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSidebarOverlay() {
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = true
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideSidebarOverlay() {
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = false
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||||
|
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestID = UUID()
|
||||||
|
openingSelectionRequestID = requestID
|
||||||
|
setSidebarHighlight(selection)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await viewModel.selectForNavigation(selection)
|
||||||
|
guard openingSelectionRequestID == requestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showRoute(PhoneRoute.from(selection: selection))
|
||||||
|
openingSelectionRequestID = nil
|
||||||
|
clearSidebarHighlight(selection, after: .milliseconds(260))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSidebarHighlight(_ selection: SidebarSelection) {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
sidebarHighlightSelection = selection
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
sidebarHighlightClearTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: delay)
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
sidebarHighlightSelection == selection,
|
||||||
|
openingSelectionRequestID == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sidebarHighlightSelection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearSidebarHighlight() {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
openingSelectionRequestID = nil
|
||||||
|
sidebarHighlightSelection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginSidebarSwipe(containerWidth: CGFloat) {
|
||||||
|
let update = {
|
||||||
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
|
sidebarSwipeIsActive = true
|
||||||
|
sidebarSwipeHasLatched = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||||
|
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||||
|
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
|
offset: nextOffset,
|
||||||
|
width: containerWidth,
|
||||||
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
|
)
|
||||||
|
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction) {
|
||||||
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
|
sidebarSwipeOffset = nextOffset
|
||||||
|
sidebarSwipeHasLatched = nextLatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishSidebarSwipe(
|
||||||
|
translationX: CGFloat,
|
||||||
|
containerWidth: CGFloat,
|
||||||
|
velocityX: CGFloat,
|
||||||
|
didFinish: Bool
|
||||||
|
) {
|
||||||
|
guard sidebarSwipeIsActive else {
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||||
|
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
|
offset: finalOffset,
|
||||||
|
width: containerWidth,
|
||||||
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
|
)
|
||||||
|
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
||||||
|
|
||||||
|
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
||||||
|
offset: finalOffset,
|
||||||
|
velocityX: velocityX,
|
||||||
|
width: containerWidth,
|
||||||
|
isLatched: finalLatched
|
||||||
|
) {
|
||||||
|
completeSidebarSwipe()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeSidebarSwipe() {
|
||||||
|
guard !sidebarSwipeIsCompleting else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarSwipeIsCompleting = true
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = true
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||||
|
let currentOffset = sidebarSwipeOffset
|
||||||
|
let reset = {
|
||||||
|
sidebarSwipeOffset = 0
|
||||||
|
sidebarSwipeIsActive = false
|
||||||
|
sidebarSwipeIsCompleting = false
|
||||||
|
sidebarSwipeHasLatched = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(
|
||||||
|
SidebarOverlaySwipeMetrics.springAnimation(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: 0,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SidebarOverlaySwipeMetrics {
|
||||||
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
BackSwipeMetrics.progress(for: offset, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||||
|
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||||
|
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
|
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||||
|
(1 - min(max(progress, 0), 1)) * 18
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||||
|
min(max(progress, 0), 1) * 14
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
|
||||||
|
1 - (min(max(progress, 0), 1) * 0.22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneOverlayBlurBand: View {
|
||||||
|
var edge: VerticalEdge
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.opacity(0.34)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: gradientColors,
|
||||||
|
startPoint: edge == .top ? .top : .bottom,
|
||||||
|
endPoint: edge == .top ? .bottom : .top
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gradientColors: [Color] {
|
||||||
|
[
|
||||||
|
Color.black.opacity(0.94),
|
||||||
|
SybilTheme.background.opacity(0.78),
|
||||||
|
Color.black.opacity(0)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRoot: View {
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@Binding var path: [PhoneRoute]
|
var highlightedSelection: SidebarSelection?
|
||||||
|
var onSelect: (SidebarSelection) -> Void
|
||||||
|
var onRoute: (PhoneRoute) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -60,50 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: { item in
|
||||||
.tint(SybilTheme.primary)
|
highlightedSelection == item.selection
|
||||||
Text("Loading conversations…")
|
},
|
||||||
.font(.sybil(.footnote))
|
onSelect: { item in
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
onSelect(item.selection)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
)
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
|
||||||
SybilPhoneSidebarRow(item: item)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -118,19 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
path = [.settings]
|
viewModel.openSettings()
|
||||||
|
onRoute(.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
path = [.draftSearch]
|
onRoute(.draftSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
path = [.draftChat]
|
onRoute(.draftChat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -168,79 +578,40 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRow: View {
|
|
||||||
var item: SidebarItem
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneDestinationView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Binding var composerFocusRequest: Int
|
||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
|
let onRequestNewChat: (() -> Void)?
|
||||||
|
let onShowSidebar: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(viewModel: viewModel)
|
SybilWorkspaceView(
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
viewModel: viewModel,
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
composerFocusRequest: composerFocusRequest,
|
||||||
.task(id: route) {
|
navigationLeadingControl: .showSidebar,
|
||||||
applyRoute()
|
onShowSidebar: onShowSidebar,
|
||||||
}
|
onRequestBack: onRequestBack,
|
||||||
|
onRequestNewChat: onRequestNewChat
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.task(id: route) {
|
||||||
|
applyRoute()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyRoute() {
|
private func applyRoute() {
|
||||||
switch route {
|
switch route {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.chat(chatID))
|
viewModel.select(.chat(chatID))
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SybilHomeScreenQuickAction {
|
||||||
|
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SybilQuickActionRouter: ObservableObject {
|
||||||
|
public static let shared = SybilQuickActionRouter()
|
||||||
|
|
||||||
|
@Published public private(set) var quickQuestionPresentationRequest = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func requestQuickQuestionPresentation() {
|
||||||
|
quickQuestionPresentationRequest += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilQuickQuestionView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var focusRequest: Int
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@FocusState private var promptFocused: Bool
|
||||||
|
|
||||||
|
private var hasAnswerContent: Bool {
|
||||||
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header
|
||||||
|
|
||||||
|
answerArea
|
||||||
|
|
||||||
|
composer
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.background(SybilTheme.backgroundGradient)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.task(id: focusRequest) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(260))
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
promptFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 21, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
|
||||||
|
Text("Quick question")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var answerArea: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if hasAnswerContent {
|
||||||
|
ForEach(viewModel.quickQuestionMessages) { message in
|
||||||
|
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.quickQuestionError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.black.opacity(0.36))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
|
TextField(
|
||||||
|
"Ask anything...",
|
||||||
|
text: Binding(
|
||||||
|
get: { viewModel.quickQuestionPrompt },
|
||||||
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
|
),
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.focused($promptFocused)
|
||||||
|
.font(.body)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.autocorrectionDisabled(false)
|
||||||
|
.lineLimit(1 ... 6)
|
||||||
|
.submitLabel(.send)
|
||||||
|
.onSubmit(submitQuestion)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.composerGradient)
|
||||||
|
.opacity(0.98)
|
||||||
|
)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Button(action: submitQuestion) {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
viewModel.canSendQuickQuestion
|
||||||
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!viewModel.canSendQuickQuestion)
|
||||||
|
.accessibilityLabel("Ask quick question")
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var convertButton: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||||
|
if didConvert {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Chat", systemImage: "bubble.left")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.disabled(!viewModel.canConvertQuickQuestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
providerMenu
|
||||||
|
modelMenu
|
||||||
|
convertButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.providerOptions, id: \.self) { provider in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionProvider(provider)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionProvider == provider {
|
||||||
|
Label(provider.displayName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(provider.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelMenu: some View {
|
||||||
|
Menu {
|
||||||
|
if viewModel.quickQuestionProviderModelOptions.isEmpty {
|
||||||
|
Text("No models")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionModel(model)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionModel == model {
|
||||||
|
Label(model, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question model")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitQuestion() {
|
||||||
|
guard viewModel.canSendQuickQuestion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFocused = false
|
||||||
|
_ = viewModel.sendQuickQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionPickerPill: View {
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionMessageView: View {
|
||||||
|
var message: Message
|
||||||
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var isPendingAssistant: Bool {
|
||||||
|
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||||
|
isSending &&
|
||||||
|
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let metadata = message.toolCallMetadata {
|
||||||
|
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else if isPendingAssistant {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Thinking...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Markdown(message.content)
|
||||||
|
.font(.body)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.text.opacity(0.96))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
|
||||||
|
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return fallbackContent
|
||||||
|
}
|
||||||
|
return "Ran \(metadata.toolName ?? "tool")."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ struct SybilSearchResultsView: View {
|
|||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
var isStartingChat: Bool = false
|
var isStartingChat: Bool = false
|
||||||
|
var topContentInset: CGFloat = 0
|
||||||
|
var bottomContentInset: CGFloat = 0
|
||||||
var onStartChat: (() -> Void)? = nil
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -98,7 +100,8 @@ struct SybilSearchResultsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 20)
|
.padding(.top, 20 + topContentInset)
|
||||||
|
.padding(.bottom, 20 + bottomContentInset)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import SwiftUI
|
|||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
private func iconName(for item: SidebarItem) -> String {
|
|
||||||
switch item.kind {
|
|
||||||
case .chat: return "message"
|
|
||||||
case .search: return "globe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSelected(_ item: SidebarItem) -> Bool {
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||||
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||||
}
|
}
|
||||||
@@ -57,119 +50,14 @@ struct SybilSidebarView: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: isSelected,
|
||||||
.tint(SybilTheme.primary)
|
onSelect: { item in
|
||||||
Text("Loading conversations…")
|
viewModel.select(item.selection)
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
)
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
Button {
|
|
||||||
viewModel.select(item.selection)
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: iconName(for: item))
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.overlay(SybilTheme.border)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.openSettings()
|
|
||||||
} label: {
|
|
||||||
Label("Settings", systemImage: "gearshape")
|
|
||||||
.font(.sybil(.subheadline, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(10)
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
@@ -178,6 +66,17 @@ struct SybilSidebarView: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
SybilWordmark(size: 18)
|
SybilWordmark(size: 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
viewModel.openSettings()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Settings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,3 +106,206 @@ struct SybilSidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarItemList: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var isSelected: (SidebarItem) -> Bool
|
||||||
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
@State private var renameTarget: SidebarItem?
|
||||||
|
@State private var renameTitle = ""
|
||||||
|
|
||||||
|
private var isRenameAlertPresented: Binding<Bool> {
|
||||||
|
Binding {
|
||||||
|
renameTarget != nil
|
||||||
|
} set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
renameTarget = nil
|
||||||
|
renameTitle = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Loading conversations…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
Button {
|
||||||
|
onSelect(item)
|
||||||
|
} label: {
|
||||||
|
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.kind == .chat {
|
||||||
|
Button {
|
||||||
|
renameTarget = item
|
||||||
|
renameTitle = item.title
|
||||||
|
} label: {
|
||||||
|
Label("Rename", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||||
|
TextField("Title", text: $renameTitle)
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
renameTarget = nil
|
||||||
|
renameTitle = ""
|
||||||
|
}
|
||||||
|
Button("Save") {
|
||||||
|
let target = renameTarget
|
||||||
|
let title = renameTitle
|
||||||
|
renameTarget = nil
|
||||||
|
renameTitle = ""
|
||||||
|
|
||||||
|
if let target, case let .chat(chatID) = target.selection {
|
||||||
|
Task {
|
||||||
|
await viewModel.renameChat(chatID: chatID, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarRow: View {
|
||||||
|
var item: SidebarItem
|
||||||
|
var isSelected: Bool
|
||||||
|
|
||||||
|
private var isHighlighted: Bool {
|
||||||
|
isSelected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch item.kind {
|
||||||
|
case .chat: return "message"
|
||||||
|
case .search: return "globe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
if item.starred {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if item.isRunning {
|
||||||
|
SybilSidebarActivityIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarActivityIndicator: View {
|
||||||
|
var body: some View {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.accent)
|
||||||
|
.scaleEffect(0.82)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.accessibilityLabel("Generating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let registeredFonts: Void = {
|
private static let registeredFonts: Void = {
|
||||||
for fontName in ["Inter", "Orbitron"] {
|
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
||||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||||
else {
|
else {
|
||||||
@@ -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()
|
||||||
@@ -178,8 +179,19 @@ enum SybilTheme {
|
|||||||
static var toolCallGradient: LinearGradient {
|
static var toolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
|
Color(red: 0.01, green: 0.15, blue: 0.17),
|
||||||
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
|
Color(red: 0.03, green: 0.09, blue: 0.15)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var runningToolCallGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.30, green: 0.19, blue: 0.04),
|
||||||
|
Color(red: 0.09, green: 0.05, blue: 0.17)
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
@@ -189,8 +201,8 @@ enum SybilTheme {
|
|||||||
static var failedToolCallGradient: LinearGradient {
|
static var failedToolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
danger.opacity(0.18),
|
Color(red: 0.27, green: 0.04, blue: 0.10),
|
||||||
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
|
Color(red: 0.15, green: 0.03, blue: 0.07)
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
@@ -203,7 +215,7 @@ struct SybilWordmark: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("SYBIL")
|
Text("SYBIL")
|
||||||
.font(.custom("Orbitron", size: size))
|
.font(.custom("Stalinist One", size: size))
|
||||||
.fontWeight(.black)
|
.fontWeight(.black)
|
||||||
.tracking(0)
|
.tracking(0)
|
||||||
.foregroundStyle(SybilTheme.brandGradient)
|
.foregroundStyle(SybilTheme.brandGradient)
|
||||||
|
|||||||
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
require "dotenv"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(command)
|
||||||
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_project_settings
|
||||||
|
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_build_number
|
||||||
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = nil
|
||||||
|
|
||||||
|
if app_store_connect_key_options
|
||||||
|
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
if api_key
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store-connect",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "automatic",
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||||
|
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||||
|
|
||||||
|
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||||
|
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||||
|
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||||
|
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||||
|
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||||
|
|
||||||
|
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||||
|
sh([
|
||||||
|
"xcrun altool",
|
||||||
|
"--upload-package #{ipa_path.shellescape}",
|
||||||
|
"--platform ios",
|
||||||
|
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||||
|
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||||
|
"--bundle-version #{build_number.shellescape}",
|
||||||
|
"--bundle-short-version-string #{version.shellescape}",
|
||||||
|
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||||
|
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||||
|
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||||
|
"--show-progress"
|
||||||
|
].join(" "))
|
||||||
|
end
|
||||||
|
end
|
||||||
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
30
ios/justfile
@@ -1,10 +1,32 @@
|
|||||||
|
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
||||||
|
simulator_name := "iPhone 16e"
|
||||||
|
derived_data := "build/DerivedData"
|
||||||
|
|
||||||
default:
|
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 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||||
|
|
||||||
|
run: generate
|
||||||
|
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||||
|
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||||
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
beta:
|
||||||
|
fastlane ios beta
|
||||||
|
|
||||||
|
screenshot path="build/sybil-screenshot.png":
|
||||||
|
mkdir -p "$(dirname '{{path}}')"
|
||||||
|
xcrun simctl io booted screenshot '{{path}}'
|
||||||
|
|||||||
BIN
original_assets/character-busy.mp4
Normal file
BIN
original_assets/character-idle.mp4
Normal file
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI / Anthropic / xAI (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,9 +43,23 @@ 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`)
|
||||||
|
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
||||||
|
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
||||||
|
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
||||||
|
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
||||||
|
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||||
|
- `CHAT_CODEX_REMOTE_WORKDIR` (`/workspace/sybil-codex` by default; created and reused on the devbox)
|
||||||
|
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||||
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||||
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||||
|
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||||
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"prebuild": "node scripts/ensure-prisma-client.mjs",
|
"prebuild": "node scripts/ensure-prisma-client.mjs",
|
||||||
"dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
|
"dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"test": "node --test --import tsx tests/**/*.test.ts",
|
||||||
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||||
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
|
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
|
||||||
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",
|
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
59
server/src/active-streams.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type SseStreamEvent = {
|
||||||
|
event: string;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseStreamListener = (event: SseStreamEvent) => void;
|
||||||
|
|
||||||
|
export class ActiveSseStream {
|
||||||
|
private readonly events: SseStreamEvent[] = [];
|
||||||
|
private readonly listeners = new Set<SseStreamListener>();
|
||||||
|
private completed = false;
|
||||||
|
private resolveDone!: () => void;
|
||||||
|
|
||||||
|
readonly done: Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.done = new Promise((resolve) => {
|
||||||
|
this.resolveDone = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCompleted() {
|
||||||
|
return this.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data: unknown) {
|
||||||
|
if (this.completed) return;
|
||||||
|
const entry = { event, data };
|
||||||
|
this.events.push(entry);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(finalEvent?: SseStreamEvent) {
|
||||||
|
if (this.completed) return;
|
||||||
|
if (finalEvent) {
|
||||||
|
this.emit(finalEvent.event, finalEvent.data);
|
||||||
|
}
|
||||||
|
this.completed = true;
|
||||||
|
this.listeners.clear();
|
||||||
|
this.resolveDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: SseStreamListener) {
|
||||||
|
for (const event of this.events) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.completed) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/src/browser-fetch-headers.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const CHROMIUM_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
|
||||||
|
|
||||||
|
export const FETCH_URL_ACCEPT =
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
|
||||||
|
|
||||||
|
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"User-Agent": CHROMIUM_USER_AGENT,
|
||||||
|
Accept: accept,
|
||||||
|
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...buildBrowserLikeRequestHeaders(accept),
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
|
|||||||
z.string().trim().url().optional()
|
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;
|
||||||
@@ -20,6 +27,34 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
|||||||
z.enum(["exa", "searxng"]).default("exa")
|
z.enum(["exa", "searxng"]).default("exa")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BooleanFlagSchema = z.preprocess((value) => {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||||
|
return value;
|
||||||
|
}, z.boolean().default(false));
|
||||||
|
|
||||||
|
const OptionalTrimmedStringSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||||
|
z.string().trim().min(1).optional()
|
||||||
|
);
|
||||||
|
|
||||||
|
function defaultedPositiveInt(defaultValue: number) {
|
||||||
|
return z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||||
|
z.coerce.number().int().positive().default(defaultValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultedTrimmedString(defaultValue: string) {
|
||||||
|
return z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||||
|
z.string().trim().min(1).default(defaultValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
PORT: z.coerce.number().int().positive().default(8787),
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
HOST: z.string().default("0.0.0.0"),
|
HOST: z.string().default("0.0.0.0"),
|
||||||
@@ -31,11 +66,30 @@ 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.
|
||||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||||
SEARXNG_BASE_URL: OptionalUrlSchema,
|
SEARXNG_BASE_URL: OptionalUrlSchema,
|
||||||
|
CHAT_MAX_TOOL_ROUNDS: defaultedPositiveInt(100),
|
||||||
|
|
||||||
|
// Optional chat-mode Codex tool. When enabled, the server SSHes into a remote
|
||||||
|
// devbox and runs `codex exec` in a persistent scratch directory there.
|
||||||
|
CHAT_CODEX_TOOL_ENABLED: BooleanFlagSchema,
|
||||||
|
CHAT_CODEX_REMOTE_HOST: OptionalTrimmedStringSchema,
|
||||||
|
CHAT_CODEX_REMOTE_USER: OptionalTrimmedStringSchema,
|
||||||
|
CHAT_CODEX_REMOTE_PORT: defaultedPositiveInt(22),
|
||||||
|
CHAT_CODEX_REMOTE_WORKDIR: defaultedTrimmedString("/workspace/sybil-codex"),
|
||||||
|
CHAT_CODEX_SSH_KEY_PATH: OptionalTrimmedStringSchema,
|
||||||
|
CHAT_CODEX_SSH_PRIVATE_KEY_B64: OptionalTrimmedStringSchema,
|
||||||
|
CHAT_CODEX_EXEC_TIMEOUT_MS: defaultedPositiveInt(600_000),
|
||||||
|
|
||||||
|
// Optional arbitrary shell tool that runs only on the configured devbox.
|
||||||
|
CHAT_SHELL_TOOL_ENABLED: BooleanFlagSchema,
|
||||||
|
CHAT_SHELL_EXEC_TIMEOUT_MS: defaultedPositiveInt(120_000),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
if (value.CHAT_WEB_SEARCH_ENGINE === "searxng" && !value.SEARXNG_BASE_URL) {
|
if (value.CHAT_WEB_SEARCH_ENGINE === "searxng" && !value.SEARXNG_BASE_URL) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -44,6 +98,14 @@ const EnvSchema = z.object({
|
|||||||
message: "SEARXNG_BASE_URL is required when CHAT_WEB_SEARCH_ENGINE=searxng",
|
message: "SEARXNG_BASE_URL is required when CHAT_WEB_SEARCH_ENGINE=searxng",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["CHAT_CODEX_REMOTE_HOST"],
|
||||||
|
message: "CHAT_CODEX_REMOTE_HOST is required when CHAT_CODEX_TOOL_ENABLED=true or CHAT_SHELL_TOOL_ENABLED=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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({
|
||||||
|
bodyLimit: 32 * 1024 * 1024,
|
||||||
disableRequestLogging: true,
|
disableRequestLogging: true,
|
||||||
logger: {
|
logger: {
|
||||||
transport: {
|
transport: {
|
||||||
@@ -20,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,
|
||||||
@@ -79,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 });
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { convert as htmlToText } from "html-to-text";
|
import { convert as htmlToText } from "html-to-text";
|
||||||
import type OpenAI from "openai";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||||
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 type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = 4;
|
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||||
const DEFAULT_WEB_RESULTS = 5;
|
const DEFAULT_WEB_RESULTS = 5;
|
||||||
const MAX_WEB_RESULTS = 10;
|
const MAX_WEB_RESULTS = 10;
|
||||||
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
||||||
const MAX_FETCH_MAX_CHARACTERS = 50_000;
|
const MAX_FETCH_MAX_CHARACTERS = 50_000;
|
||||||
const FETCH_TIMEOUT_MS = 12_000;
|
const FETCH_TIMEOUT_MS = 12_000;
|
||||||
|
const MAX_CODEX_PROMPT_CHARACTERS = 60_000;
|
||||||
|
const DEFAULT_CODEX_MAX_OUTPUT_CHARACTERS = 24_000;
|
||||||
|
const MAX_CODEX_MAX_OUTPUT_CHARACTERS = 80_000;
|
||||||
|
const MAX_SHELL_COMMAND_CHARACTERS = 20_000;
|
||||||
|
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
|
||||||
|
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
|
||||||
|
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
|
||||||
|
export const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const WebSearchArgsSchema = z
|
const WebSearchArgsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -32,7 +47,79 @@ const FetchUrlArgsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const CHAT_TOOLS: any[] = [
|
const CodexExecArgsSchema = z
|
||||||
|
.object({
|
||||||
|
prompt: z.string().trim().min(1).max(MAX_CODEX_PROMPT_CHARACTERS),
|
||||||
|
maxCharacters: z.coerce.number().int().min(1_000).max(MAX_CODEX_MAX_OUTPUT_CHARACTERS).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type CodexExecArgs = z.infer<typeof CodexExecArgsSchema>;
|
||||||
|
|
||||||
|
const ShellExecArgsSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.string().trim().min(1).max(MAX_SHELL_COMMAND_CHARACTERS),
|
||||||
|
maxCharacters: z.coerce.number().int().min(1_000).max(MAX_SHELL_MAX_OUTPUT_CHARACTERS).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type ShellExecArgs = z.infer<typeof ShellExecArgsSchema>;
|
||||||
|
|
||||||
|
const CODEX_EXEC_TOOL = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "codex_exec",
|
||||||
|
description:
|
||||||
|
"Delegate a coding, terminal, or multi-step software task to a persistent remote Codex CLI workspace. Use for complex code changes, repository inspection, running programs/tests, debugging build failures, or other tasks that need a real shell. The task runs non-interactively; the remote Codex instance must make reasonable assumptions, complete the task, and return a final summary with relevant stdout/stderr.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
prompt: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"A complete, self-contained instruction for the remote Codex instance. Include the goal, relevant context, constraints, and what result to report back.",
|
||||||
|
},
|
||||||
|
maxCharacters: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1_000,
|
||||||
|
maximum: MAX_CODEX_MAX_OUTPUT_CHARACTERS,
|
||||||
|
description: "Maximum stdout/stderr characters returned to the model (default 24000).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["prompt"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHELL_EXEC_TOOL = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "shell_exec",
|
||||||
|
description:
|
||||||
|
"Run an arbitrary non-interactive shell command on the configured remote devbox, starting in the persistent scratch workspace. Use for quick Python scripts, calculations, file inspection, package/tool checks, tests, and command-line work that needs a real shell. This does not run inside the Sybil server container.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Shell command to run on the devbox. The command is executed with bash -lc when bash exists, otherwise sh -lc, starting in the persistent scratch workspace.",
|
||||||
|
},
|
||||||
|
maxCharacters: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1_000,
|
||||||
|
maximum: MAX_SHELL_MAX_OUTPUT_CHARACTERS,
|
||||||
|
description: "Maximum stdout/stderr characters returned to the model (default 24000).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_CHAT_TOOLS: any[] = [
|
||||||
{
|
{
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
@@ -94,24 +181,72 @@ const CHAT_TOOLS: any[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CHAT_TOOLS: any[] = [
|
||||||
|
...BASE_CHAT_TOOLS,
|
||||||
|
...(env.CHAT_CODEX_TOOL_ENABLED ? [CODEX_EXEC_TOOL] : []),
|
||||||
|
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
const enabled = getEnabledToolSet(params);
|
||||||
|
return CHAT_TOOLS.filter((tool) => {
|
||||||
|
const name = getToolName(tool);
|
||||||
|
return name ? enabled.has(name) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
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. " +
|
||||||
"Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " +
|
"Use web_search for discovery and recent facts, and 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. " +
|
"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. " +
|
||||||
|
(env.CHAT_CODEX_TOOL_ENABLED
|
||||||
|
? "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. "
|
||||||
|
: "") +
|
||||||
|
(env.CHAT_SHELL_TOOL_ENABLED
|
||||||
|
? "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.";
|
"Do not fabricate tool outputs; reason only from provided tool results.";
|
||||||
|
|
||||||
type ToolRunOutcome = {
|
export type ToolRunOutcome = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolAwareUsage = {
|
export type ToolAwareUsage = {
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolAwareCompletionResult = {
|
export type ToolAwareCompletionResult = {
|
||||||
text: string;
|
text: string;
|
||||||
usage?: ToolAwareUsage;
|
usage?: ToolAwareUsage;
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
@@ -123,10 +258,12 @@ export type ToolAwareStreamingEvent =
|
|||||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||||
| { type: "done"; result: ToolAwareCompletionResult };
|
| { type: "done"; result: ToolAwareCompletionResult };
|
||||||
|
|
||||||
type ToolAwareCompletionParams = {
|
export type ToolAwareCompletionParams = {
|
||||||
client: OpenAI;
|
client: any;
|
||||||
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>;
|
||||||
@@ -137,15 +274,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;
|
||||||
};
|
};
|
||||||
@@ -173,10 +312,13 @@ function toSingleLine(value: string, maxLength = 220) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
|
||||||
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
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.";
|
||||||
}
|
}
|
||||||
@@ -185,12 +327,42 @@ 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.";
|
||||||
}
|
}
|
||||||
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
|
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === "codex_exec") {
|
||||||
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
||||||
|
}
|
||||||
|
return prompt ? `Codex task '${toSingleLine(prompt, 120)}' failed.${errSuffix}` : `Codex task failed.${errSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "shell_exec") {
|
||||||
|
const command = typeof args.command === "string" ? args.command.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
? `Devbox shell command '${toSingleLine(command, 120)}' 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}'.`;
|
||||||
}
|
}
|
||||||
@@ -249,26 +421,22 @@ function extractHtmlTitle(html: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
export function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
const normalized = messages.map((m) => {
|
const enabled = getEnabledToolSet(params);
|
||||||
if (m.role === "tool") {
|
return (
|
||||||
const name = m.name?.trim() || "tool";
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
return {
|
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
|
||||||
role: "user",
|
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
|
||||||
content: `Tool output (${name}):\n${m.content}`,
|
"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")
|
||||||
if (m.role === "assistant" || m.role === "system" || m.role === "user") {
|
? "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. "
|
||||||
const out: any = { role: m.role, content: m.content };
|
: "") +
|
||||||
if (m.name && (m.role === "assistant" || m.role === "user")) {
|
(enabled.has("shell_exec")
|
||||||
out.name = m.name;
|
? "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. "
|
||||||
}
|
: "") +
|
||||||
return out;
|
"Do not fabricate tool outputs; reason only from provided tool results."
|
||||||
}
|
);
|
||||||
return { role: "user", content: m.content };
|
|
||||||
});
|
|
||||||
|
|
||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
@@ -368,10 +536,7 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
|
|||||||
response = await fetch(parsed.toString(), {
|
response = await fetch(parsed.toString(), {
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: buildBrowserLikeNavigationHeaders(),
|
||||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
|
||||||
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -417,13 +582,232 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string) {
|
||||||
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevboxSshTarget() {
|
||||||
|
const host = env.CHAT_CODEX_REMOTE_HOST;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error("CHAT_CODEX_REMOTE_HOST not set");
|
||||||
|
}
|
||||||
|
if (!env.CHAT_CODEX_REMOTE_USER || host.includes("@")) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
return `${env.CHAT_CODEX_REMOTE_USER}@${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteCodexCommand(prompt: string) {
|
||||||
|
const workdir = env.CHAT_CODEX_REMOTE_WORKDIR.trim();
|
||||||
|
const wrappedPrompt = [
|
||||||
|
"You are running in a non-interactive batch environment.",
|
||||||
|
"",
|
||||||
|
"Rules:",
|
||||||
|
"- Do not ask questions or wait for user input.",
|
||||||
|
"- Do not use interactive commands, editors, pagers, or prompts.",
|
||||||
|
"- If details are ambiguous, make a reasonable assumption and continue.",
|
||||||
|
"- Complete the task in one run, including any requested file edits, commands, and verification.",
|
||||||
|
"- End with a concise final report that includes changed files, commands run, and outcomes.",
|
||||||
|
"",
|
||||||
|
"Task:",
|
||||||
|
prompt,
|
||||||
|
].join("\n");
|
||||||
|
const codexCommand =
|
||||||
|
`codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check ${shellQuote(wrappedPrompt)} < /dev/null`;
|
||||||
|
return `mkdir -p ${shellQuote(workdir)} && cd ${shellQuote(workdir)} && ${codexCommand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteShellCommand(command: string) {
|
||||||
|
const workdir = env.CHAT_CODEX_REMOTE_WORKDIR.trim();
|
||||||
|
const quotedCommand = shellQuote(command);
|
||||||
|
return (
|
||||||
|
`mkdir -p ${shellQuote(workdir)} && cd ${shellQuote(workdir)} && ` +
|
||||||
|
`if command -v bash >/dev/null 2>&1; then bash -lc ${quotedCommand}; else sh -lc ${quotedCommand}; fi`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withDevboxSshKeyPath<T>(fn: (keyPath?: string) => Promise<T>) {
|
||||||
|
if (env.CHAT_CODEX_SSH_KEY_PATH) {
|
||||||
|
return fn(env.CHAT_CODEX_SSH_KEY_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.CHAT_CODEX_SSH_PRIVATE_KEY_B64) {
|
||||||
|
return fn(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "sybil-codex-ssh-"));
|
||||||
|
const keyPath = path.join(tmpDir, "id");
|
||||||
|
try {
|
||||||
|
await writeFile(keyPath, Buffer.from(env.CHAT_CODEX_SSH_PRIVATE_KEY_B64, "base64"), { mode: 0o600 });
|
||||||
|
return await fn(keyPath);
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipRemoteOutput(value: string, maxCharacters: number) {
|
||||||
|
if (value.length <= maxCharacters) {
|
||||||
|
return { text: value, truncated: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `${value.slice(0, maxCharacters)}\n\n[truncated ${value.length - maxCharacters} characters]`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferOrStringToString(value: unknown) {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCodexExecTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
|
if (!env.CHAT_CODEX_TOOL_ENABLED) {
|
||||||
|
return { ok: false, error: "codex_exec is disabled." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: CodexExecArgs = CodexExecArgsSchema.parse(input);
|
||||||
|
const maxCharacters = args.maxCharacters ?? DEFAULT_CODEX_MAX_OUTPUT_CHARACTERS;
|
||||||
|
const sshTarget = buildDevboxSshTarget();
|
||||||
|
const remoteCommand = buildRemoteCodexCommand(args.prompt);
|
||||||
|
|
||||||
|
const run = async (keyPath?: string) => {
|
||||||
|
const sshArgs = [
|
||||||
|
"-n",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
"-o",
|
||||||
|
"UserKnownHostsFile=/tmp/sybil-codex-known-hosts",
|
||||||
|
"-p",
|
||||||
|
String(env.CHAT_CODEX_REMOTE_PORT),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (keyPath) {
|
||||||
|
sshArgs.push("-i", keyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
sshArgs.push(sshTarget, remoteCommand);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync("ssh", sshArgs, {
|
||||||
|
timeout: env.CHAT_CODEX_EXEC_TIMEOUT_MS,
|
||||||
|
maxBuffer: REMOTE_EXEC_MAX_BUFFER_BYTES,
|
||||||
|
});
|
||||||
|
const stdout = clipRemoteOutput(bufferOrStringToString(result.stdout), maxCharacters);
|
||||||
|
const stderr = clipRemoteOutput(bufferOrStringToString(result.stderr), Math.min(maxCharacters, 12_000));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
host: env.CHAT_CODEX_REMOTE_HOST,
|
||||||
|
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
|
||||||
|
stdout: stdout.text,
|
||||||
|
stderr: stderr.text,
|
||||||
|
stdoutTruncated: stdout.truncated,
|
||||||
|
stderrTruncated: stderr.truncated,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const stdout = clipRemoteOutput(bufferOrStringToString(err?.stdout), maxCharacters);
|
||||||
|
const stderr = clipRemoteOutput(bufferOrStringToString(err?.stderr), Math.min(maxCharacters, 12_000));
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err?.killed
|
||||||
|
? `Remote Codex command timed out after ${env.CHAT_CODEX_EXEC_TIMEOUT_MS}ms.`
|
||||||
|
: err?.message ?? String(err),
|
||||||
|
exitCode: typeof err?.code === "number" ? err.code : null,
|
||||||
|
signal: typeof err?.signal === "string" ? err.signal : null,
|
||||||
|
host: env.CHAT_CODEX_REMOTE_HOST,
|
||||||
|
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
|
||||||
|
stdout: stdout.text,
|
||||||
|
stderr: stderr.text,
|
||||||
|
stdoutTruncated: stdout.truncated,
|
||||||
|
stderrTruncated: stderr.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return withDevboxSshKeyPath(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runShellExecTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
|
if (!env.CHAT_SHELL_TOOL_ENABLED) {
|
||||||
|
return { ok: false, error: "shell_exec is disabled." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: ShellExecArgs = ShellExecArgsSchema.parse(input);
|
||||||
|
const maxCharacters = args.maxCharacters ?? DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS;
|
||||||
|
const sshTarget = buildDevboxSshTarget();
|
||||||
|
const remoteCommand = buildRemoteShellCommand(args.command);
|
||||||
|
|
||||||
|
const run = async (keyPath?: string) => {
|
||||||
|
const sshArgs = [
|
||||||
|
"-n",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
"-o",
|
||||||
|
"UserKnownHostsFile=/tmp/sybil-codex-known-hosts",
|
||||||
|
"-p",
|
||||||
|
String(env.CHAT_CODEX_REMOTE_PORT),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (keyPath) {
|
||||||
|
sshArgs.push("-i", keyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
sshArgs.push(sshTarget, remoteCommand);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync("ssh", sshArgs, {
|
||||||
|
timeout: env.CHAT_SHELL_EXEC_TIMEOUT_MS,
|
||||||
|
maxBuffer: REMOTE_EXEC_MAX_BUFFER_BYTES,
|
||||||
|
});
|
||||||
|
const stdout = clipRemoteOutput(bufferOrStringToString(result.stdout), maxCharacters);
|
||||||
|
const stderr = clipRemoteOutput(bufferOrStringToString(result.stderr), Math.min(maxCharacters, 12_000));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
host: env.CHAT_CODEX_REMOTE_HOST,
|
||||||
|
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
|
||||||
|
command: args.command,
|
||||||
|
stdout: stdout.text,
|
||||||
|
stderr: stderr.text,
|
||||||
|
stdoutTruncated: stdout.truncated,
|
||||||
|
stderrTruncated: stderr.truncated,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const stdout = clipRemoteOutput(bufferOrStringToString(err?.stdout), maxCharacters);
|
||||||
|
const stderr = clipRemoteOutput(bufferOrStringToString(err?.stderr), Math.min(maxCharacters, 12_000));
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err?.killed
|
||||||
|
? `Remote shell command timed out after ${env.CHAT_SHELL_EXEC_TIMEOUT_MS}ms.`
|
||||||
|
: err?.message ?? String(err),
|
||||||
|
exitCode: typeof err?.code === "number" ? err.code : null,
|
||||||
|
signal: typeof err?.signal === "string" ? err.signal : null,
|
||||||
|
host: env.CHAT_CODEX_REMOTE_HOST,
|
||||||
|
workdir: env.CHAT_CODEX_REMOTE_WORKDIR,
|
||||||
|
command: args.command,
|
||||||
|
stdout: stdout.text,
|
||||||
|
stderr: stderr.text,
|
||||||
|
stdoutTruncated: stdout.truncated,
|
||||||
|
stderrTruncated: stderr.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return withDevboxSshKeyPath(run);
|
||||||
|
}
|
||||||
|
|
||||||
async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome> {
|
async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome> {
|
||||||
if (name === "web_search") return runWebSearchTool(args);
|
if (name === "web_search") return runWebSearchTool(args);
|
||||||
if (name === "fetch_url") return runFetchUrlTool(args);
|
if (name === "fetch_url") return runFetchUrlTool(args);
|
||||||
|
if (name === "codex_exec") return runCodexExecTool(args);
|
||||||
|
if (name === "shell_exec") return runShellExecTool(args);
|
||||||
return { ok: false, error: `Unknown tool: ${name}` };
|
return { ok: false, error: `Unknown tool: ${name}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolArgs(raw: unknown) {
|
export function parseToolArgs(raw: unknown) {
|
||||||
if (typeof raw !== "string") return {};
|
if (typeof raw !== "string") return {};
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return {};
|
if (!trimmed) return {};
|
||||||
@@ -434,7 +818,50 @@ function parseToolArgs(raw: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
function buildEventArgs(name: string, args: Record<string, unknown>) {
|
||||||
|
if (name === "codex_exec" && typeof args.prompt === "string") {
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
prompt: clipText(args.prompt, 1_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "shell_exec" && typeof args.command === "string") {
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
command: clipText(args.command, 1_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeDanglingToolIntent(text: string) {
|
||||||
|
const normalized = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[`*_>#-]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized.length > 800) return false;
|
||||||
|
if (/\blet me know\b/.test(normalized) || /\bif you (want|would like)\b/.test(normalized)) return false;
|
||||||
|
return (
|
||||||
|
/\b(calling|running|executing|trying|checking|testing)\b.{0,80}\b(now|it|tool|command|shell_exec|codex_exec)\b/.test(normalized) ||
|
||||||
|
/\b(let me|i'?ll|i will)\b.{0,120}\b(run|execute|call|try|check|test)\b/.test(normalized) ||
|
||||||
|
/\b(stand by|hang on|one moment)\b/.test(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||||
|
conversation.push({ role: "assistant", content: text });
|
||||||
|
conversation.push({
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
if (!usage) return false;
|
if (!usage) return false;
|
||||||
acc.inputTokens += usage.prompt_tokens ?? 0;
|
acc.inputTokens += usage.prompt_tokens ?? 0;
|
||||||
acc.outputTokens += usage.completion_tokens ?? 0;
|
acc.outputTokens += usage.completion_tokens ?? 0;
|
||||||
@@ -442,13 +869,19 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NormalizedToolCall = {
|
export function getUnstreamedText(finalText: string, streamedText: string) {
|
||||||
|
if (!finalText) return "";
|
||||||
|
if (!streamedText) return finalText;
|
||||||
|
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedToolCall = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
export function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||||
return toolCalls.map((call: any, index: number) => ({
|
return toolCalls.map((call: any, index: number) => ({
|
||||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||||
name: call?.function?.name ?? "unknown_tool",
|
name: call?.function?.name ?? "unknown_tool",
|
||||||
@@ -456,17 +889,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeToolCallAndBuildEvent(
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeToolCallAndBuildEvent(
|
||||||
|
call: NormalizedToolCall,
|
||||||
|
execution: PreparedToolCallExecution,
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||||
|
let toolResult: ToolRunOutcome;
|
||||||
|
try {
|
||||||
|
if (execution.parseError) throw execution.parseError;
|
||||||
|
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toolResult = {
|
toolResult = {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -487,11 +958,11 @@ async function executeToolCallAndBuildEvent(
|
|||||||
toolCallId: call.id,
|
toolCallId: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
status,
|
status,
|
||||||
summary: buildToolSummary(call.name, parsedArgs, status, error),
|
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||||
args: parsedArgs,
|
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),
|
||||||
};
|
};
|
||||||
@@ -502,194 +973,3 @@ async function executeToolCallAndBuildEvent(
|
|||||||
|
|
||||||
return { event, toolResult };
|
return { event, toolResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
|
||||||
const rawResponses: unknown[] = [];
|
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
let sawUsage = false;
|
|
||||||
let totalToolCalls = 0;
|
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
||||||
const completion = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: conversation,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
tools: CHAT_TOOLS,
|
|
||||||
tool_choice: "auto",
|
|
||||||
} as any);
|
|
||||||
rawResponses.push(completion);
|
|
||||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const message = completion?.choices?.[0]?.message;
|
|
||||||
if (!message) {
|
|
||||||
return {
|
|
||||||
text: "",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
||||||
if (!toolCalls.length) {
|
|
||||||
return {
|
|
||||||
text: typeof message.content === "string" ? message.content : "",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
|
|
||||||
const assistantToolCallMessage: any = {
|
|
||||||
role: "assistant",
|
|
||||||
tool_calls: normalizedToolCalls.map((call) => ({
|
|
||||||
id: call.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: call.name,
|
|
||||||
arguments: call.arguments,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if (typeof message.content === "string" && message.content.length) {
|
|
||||||
assistantToolCallMessage.content = message.content;
|
|
||||||
}
|
|
||||||
conversation.push(assistantToolCallMessage);
|
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
|
|
||||||
conversation.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: call.id,
|
|
||||||
content: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* runToolAwareOpenAIChatStream(
|
|
||||||
params: ToolAwareCompletionParams
|
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
|
||||||
const rawResponses: unknown[] = [];
|
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
let sawUsage = false;
|
|
||||||
let totalToolCalls = 0;
|
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
||||||
const stream = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: conversation,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
tools: CHAT_TOOLS,
|
|
||||||
tool_choice: "auto",
|
|
||||||
stream: true,
|
|
||||||
stream_options: { include_usage: true },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
let roundText = "";
|
|
||||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
|
||||||
|
|
||||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
|
||||||
rawResponses.push(chunk);
|
|
||||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const choice = chunk?.choices?.[0];
|
|
||||||
const deltaText = choice?.delta?.content ?? "";
|
|
||||||
if (typeof deltaText === "string" && deltaText.length) {
|
|
||||||
roundText += deltaText;
|
|
||||||
if (roundToolCalls.size === 0) {
|
|
||||||
yield { type: "delta", text: deltaText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
|
||||||
for (const toolCall of deltaToolCalls) {
|
|
||||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
|
||||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
|
||||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
|
||||||
entry.id = toolCall.id;
|
|
||||||
}
|
|
||||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
|
||||||
entry.name = toolCall.function.name;
|
|
||||||
}
|
|
||||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
|
||||||
entry.arguments += toolCall.function.arguments;
|
|
||||||
}
|
|
||||||
roundToolCalls.set(idx, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
|
||||||
.sort((a, b) => a[0] - b[0])
|
|
||||||
.map(([_, call], index) => ({
|
|
||||||
id: call.id ?? `tool_call_${round}_${index}`,
|
|
||||||
name: call.name ?? "unknown_tool",
|
|
||||||
arguments: call.arguments || "{}",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text: roundText,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
conversation.push({
|
|
||||||
role: "assistant",
|
|
||||||
tool_calls: normalizedToolCalls.map((call) => ({
|
|
||||||
id: call.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: call.name,
|
|
||||||
arguments: call.arguments,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
yield { type: "tool_call", event };
|
|
||||||
conversation.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: call.id,
|
|
||||||
content: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
114
server/src/llm/message-content.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_USER_LOCATION = "San Francisco, CA";
|
||||||
|
|
||||||
|
function currentDateString(now = new Date()) {
|
||||||
|
return now.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserLocation(userLocation?: string) {
|
||||||
|
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
|
||||||
|
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string) {
|
||||||
|
return value.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageAttachments(message: ChatMessage) {
|
||||||
|
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextAttachments(message: ChatMessage) {
|
||||||
|
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||||
|
if (!attachments.length) return null;
|
||||||
|
const label = attachments.length === 1 ? "Attached image" : "Attached images";
|
||||||
|
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||||
|
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
|
||||||
|
return [
|
||||||
|
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
|
||||||
|
`<attached_file filename="${escapeAttribute(attachment.filename)}" mime_type="${escapeAttribute(attachment.mimeType)}"${truncationNote}>`,
|
||||||
|
attachment.text,
|
||||||
|
"</attached_file>",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||||
|
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = match[1].toLowerCase();
|
||||||
|
if (mediaType !== attachment.mimeType) {
|
||||||
|
throw new Error(`Image attachment MIME type mismatch for '${attachment.filename}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
data: match[2].replace(/\s+/g, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content: buildSystemPromptAugmentation(userLocation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTopLevelSystemPrompt(messages: ChatMessage[], userLocation?: string, toolSystemPrompt?: string) {
|
||||||
|
return [toolSystemPrompt, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const attachments: ChatAttachment[] = [];
|
||||||
|
for (const entry of input) {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const kind = record.kind;
|
||||||
|
const id = typeof record.id === "string" ? record.id : "";
|
||||||
|
const filename = typeof record.filename === "string" ? record.filename : "";
|
||||||
|
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
|
||||||
|
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
|
||||||
|
|
||||||
|
if (kind === "image" && typeof record.dataUrl === "string") {
|
||||||
|
attachments.push({
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
mimeType: mimeType === "image/png" ? "image/png" : "image/jpeg",
|
||||||
|
sizeBytes,
|
||||||
|
dataUrl: record.dataUrl,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "text" && typeof record.text === "string") {
|
||||||
|
attachments.push({
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes,
|
||||||
|
text: record.text,
|
||||||
|
truncated: record.truncated === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import {
|
||||||
|
fetchProviderCatalogModels,
|
||||||
|
getProviderCatalogFallbackModels,
|
||||||
|
listModelCatalogProviders,
|
||||||
|
} from "./provider-adapters.js";
|
||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
export type ProviderModelSnapshot = {
|
export type ProviderModelSnapshot = {
|
||||||
@@ -8,20 +12,14 @@ 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 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 },
|
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
function uniqSorted(models: string[]) {
|
let catalogRefreshPromise: Promise<void> | null = null;
|
||||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
||||||
let timeoutId: NodeJS.Timeout | null = null;
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
@@ -39,24 +37,9 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProviderModels(provider: Provider) {
|
|
||||||
if (provider === "openai") {
|
|
||||||
const page = await openaiClient().models.list();
|
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "anthropic") {
|
|
||||||
const page = await anthropicClient().models.list({ limit: 200 });
|
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await xaiClient().models.list();
|
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||||
try {
|
try {
|
||||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
const models = await withTimeout(fetchProviderCatalogModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||||
modelCatalog[provider] = {
|
modelCatalog[provider] = {
|
||||||
models,
|
models,
|
||||||
loadedAt: new Date().toISOString(),
|
loadedAt: new Date().toISOString(),
|
||||||
@@ -65,35 +48,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 = getProviderCatalogFallbackModels(provider);
|
||||||
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(listModelCatalogProviders().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 listModelCatalogProviders()) {
|
||||||
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,12 +1,12 @@
|
|||||||
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 { buildToolLogMessageData } from "./chat-tools.js";
|
||||||
import { buildToolLogMessageData, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { getProviderChatAdapter } from "./provider-adapters.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> {
|
||||||
@@ -46,59 +46,24 @@ 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 adapter = getProviderChatAdapter(req.provider);
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
const r = await adapter.complete({
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
model: req.model,
|
||||||
const r = await runToolAwareOpenAIChat({
|
messages: req.messages,
|
||||||
client,
|
enabledTools: req.enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
chatId,
|
||||||
temperature: req.temperature,
|
},
|
||||||
maxTokens: req.maxTokens,
|
});
|
||||||
logContext: {
|
raw = r.raw;
|
||||||
provider: req.provider,
|
outText = r.text;
|
||||||
model: req.model,
|
usage = r.usage;
|
||||||
chatId,
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
},
|
|
||||||
});
|
|
||||||
raw = r.raw;
|
|
||||||
outText = r.text;
|
|
||||||
usage = r.usage;
|
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
|
||||||
} else if (req.provider === "anthropic") {
|
|
||||||
const client = anthropicClient();
|
|
||||||
|
|
||||||
// Anthropic splits system prompt. We'll convert first system message into system string.
|
|
||||||
const system = req.messages.find((m) => m.role === "system")?.content;
|
|
||||||
const msgs = req.messages
|
|
||||||
.filter((m) => m.role !== "system")
|
|
||||||
.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
|
|
||||||
|
|
||||||
const r = await client.messages.create({
|
|
||||||
model: req.model,
|
|
||||||
system,
|
|
||||||
max_tokens: req.maxTokens ?? 1024,
|
|
||||||
temperature: req.temperature,
|
|
||||||
messages: msgs as any,
|
|
||||||
});
|
|
||||||
raw = r;
|
|
||||||
outText = r.content
|
|
||||||
.map((c: any) => (c.type === "text" ? c.text : ""))
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Anthropic usage (SDK typing varies by version)
|
|
||||||
const ru: any = (r as any).usage;
|
|
||||||
if (ru) {
|
|
||||||
usage = {
|
|
||||||
inputTokens: ru.input_tokens,
|
|
||||||
outputTokens: ru.output_tokens,
|
|
||||||
totalTokens: (ru.input_tokens ?? 0) + (ru.output_tokens ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`unknown provider: ${req.provider}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
|
|||||||
386
server/src/llm/protocols/chat-completions-api.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import {
|
||||||
|
appendDanglingToolIntentCorrection,
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
getUnstreamedText,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
mergeUsage,
|
||||||
|
normalizeModelToolCalls,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
parts.push({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
detail: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ type: "text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ type: "text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||||
|
return parts[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
role: message.role,
|
||||||
|
content: toContentParts(message),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||||
|
out.name = message.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
|
const normalized = messages.map((message) => buildConversationMessage(message));
|
||||||
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlainMessages(messages: ChatMessage[], userLocation?: string) {
|
||||||
|
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildConversationMessage(message))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContent(message: any) {
|
||||||
|
if (typeof message?.content === "string") return message.content;
|
||||||
|
if (!Array.isArray(message?.content)) return "";
|
||||||
|
|
||||||
|
return message.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (typeof part?.text === "string") return part.text;
|
||||||
|
if (typeof part?.content === "string") return part.content;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithChatCompletionsApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractContent(message),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response: completion, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: enabledTools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(completion);
|
||||||
|
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
|
if (!toolCalls.length) {
|
||||||
|
const text = typeof message.content === "string" ? message.content : "";
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(conversation, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
|
||||||
|
const assistantToolCallMessage: any = {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (typeof message.content === "string" && message.content.length) {
|
||||||
|
assistantToolCallMessage.content = message.content;
|
||||||
|
}
|
||||||
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithChatCompletionsApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
text += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: enabledTools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
let roundText = "";
|
||||||
|
let streamedRoundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const choice = chunk?.choices?.[0];
|
||||||
|
const deltaText = choice?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
roundText += deltaText;
|
||||||
|
if (!roundHasToolCalls) {
|
||||||
|
streamedRoundText += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||||
|
if (deltaToolCalls.length) {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
}
|
||||||
|
for (const toolCall of deltaToolCalls) {
|
||||||
|
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||||
|
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||||
|
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||||
|
entry.id = toolCall.id;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||||
|
entry.name = toolCall.function.name;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||||
|
entry.arguments += toolCall.function.arguments;
|
||||||
|
}
|
||||||
|
roundToolCalls.set(idx, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([_, call], index) => ({
|
||||||
|
id: call.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call.name ?? "unknown_tool",
|
||||||
|
arguments: call.arguments || "{}",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
const assistantToolCallMessage: any = {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (roundText) {
|
||||||
|
assistantToolCallMessage.content = roundText;
|
||||||
|
}
|
||||||
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
470
server/src/llm/protocols/messages-api.ts
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import {
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
parseToolArgs,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolAwareUsage,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
type ToolRunOutcome,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
buildTopLevelSystemPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
parseImageDataUrl,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
const INTERNAL_CORRECTION =
|
||||||
|
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||||
|
|
||||||
|
function toTools(tools: any[]) {
|
||||||
|
return tools
|
||||||
|
.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return null;
|
||||||
|
return {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
input_schema: tool.function.parameters,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentBlocks(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
const source = parseImageDataUrl(attachment);
|
||||||
|
blocks.push({
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: source.mediaType,
|
||||||
|
data: source.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
blocks.push({ type: "text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
blocks.push({ type: "text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||||
|
return blocks[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "system") {
|
||||||
|
throw new Error("System messages must be handled separately for top-level-system protocols.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: toContentBlocks(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseMessages(params: ToolAwareCompletionParams) {
|
||||||
|
return params.messages.filter((message) => message.role !== "system").map((message) => buildConversationMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyToolInput(input: unknown) {
|
||||||
|
if (typeof input === "string") return input;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input ?? {});
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCalls(content: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return content
|
||||||
|
.filter((item) => item?.type === "tool_use")
|
||||||
|
.map((call: any, index: number) => ({
|
||||||
|
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call?.name ?? "unknown_tool",
|
||||||
|
arguments: stringifyToolInput(call?.input),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any) {
|
||||||
|
if (!Array.isArray(response?.content)) return "";
|
||||||
|
return response.content
|
||||||
|
.map((content: any) => (content?.type === "text" && typeof content.text === "string" ? content.text : ""))
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolResultBlock(call: NormalizedToolCall, toolResult: ToolRunOutcome) {
|
||||||
|
return {
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
is_error: !toolResult.ok,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCorrection(conversation: any[], text: string) {
|
||||||
|
conversation.push({ role: "assistant", content: text });
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: INTERNAL_CORRECTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
if (!usage) return false;
|
||||||
|
const inputTokens = usage.input_tokens ?? 0;
|
||||||
|
const outputTokens = usage.output_tokens ?? 0;
|
||||||
|
acc.inputTokens += inputTokens;
|
||||||
|
acc.outputTokens += outputTokens;
|
||||||
|
acc.totalTokens += inputTokens + outputTokens;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const response = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: buildBaseMessages(params),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, response?.usage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractText(response),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response, api: "messages" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = buildBaseMessages(params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const response = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: conversation,
|
||||||
|
tools: toTools(enabledTools),
|
||||||
|
tool_choice: { type: "auto" },
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const content = Array.isArray(response?.content) ? response.content : [];
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(content, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(response);
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolResultBlocks: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: toolResultBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let roundInputTokens = 0;
|
||||||
|
let roundOutputTokens = 0;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const stream = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: buildBaseMessages(params),
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(ev);
|
||||||
|
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||||
|
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||||
|
const delta = ev.delta.text ?? "";
|
||||||
|
if (delta) {
|
||||||
|
text += delta;
|
||||||
|
yield { type: "delta", text: delta };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ev?.type === "message_delta" && ev.usage) {
|
||||||
|
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||||
|
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawUsage) {
|
||||||
|
usageAcc.inputTokens += roundInputTokens;
|
||||||
|
usageAcc.outputTokens += roundOutputTokens;
|
||||||
|
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "messages" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = buildBaseMessages(params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const stream = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: conversation,
|
||||||
|
tools: toTools(enabledTools),
|
||||||
|
tool_choice: { type: "auto" },
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const contentByIndex = new Map<number, any>();
|
||||||
|
const toolArgumentByIndex = new Map<number, string>();
|
||||||
|
let roundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
let roundInputTokens = 0;
|
||||||
|
let roundOutputTokens = 0;
|
||||||
|
let sawRoundUsage = false;
|
||||||
|
|
||||||
|
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(ev);
|
||||||
|
|
||||||
|
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||||
|
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||||
|
sawRoundUsage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
|
||||||
|
const block = ev.content_block ?? {};
|
||||||
|
if (block.type === "tool_use") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
contentByIndex.set(ev.index, {
|
||||||
|
type: "tool_use",
|
||||||
|
id: block.id,
|
||||||
|
name: block.name,
|
||||||
|
input: block.input ?? {},
|
||||||
|
});
|
||||||
|
toolArgumentByIndex.set(ev.index, "");
|
||||||
|
} else if (block.type === "text") {
|
||||||
|
contentByIndex.set(ev.index, {
|
||||||
|
type: "text",
|
||||||
|
text: typeof block.text === "string" ? block.text : "",
|
||||||
|
});
|
||||||
|
} else if (block.type) {
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
|
||||||
|
if (ev.delta?.type === "text_delta") {
|
||||||
|
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
|
||||||
|
if (delta) {
|
||||||
|
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
|
||||||
|
if (block.type === "text") {
|
||||||
|
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
roundText += delta;
|
||||||
|
}
|
||||||
|
} else if (ev.delta?.type === "input_json_delta") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
|
||||||
|
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
|
||||||
|
const block = contentByIndex.get(ev.index);
|
||||||
|
if (block?.type === "tool_use") {
|
||||||
|
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
|
||||||
|
try {
|
||||||
|
block.input = parseToolArgs(rawArguments);
|
||||||
|
} catch {
|
||||||
|
block.input = {};
|
||||||
|
}
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "message_delta" && ev.usage) {
|
||||||
|
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||||
|
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||||
|
sawRoundUsage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawRoundUsage) {
|
||||||
|
usageAcc.inputTokens += roundInputTokens;
|
||||||
|
usageAcc.outputTokens += roundOutputTokens;
|
||||||
|
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
const assistantContent = indexedContent.map(([, block]) => block);
|
||||||
|
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
|
||||||
|
.filter(([, block]) => block?.type === "tool_use")
|
||||||
|
.map(([index, block], callIndex) => ({
|
||||||
|
id: block.id ?? `tool_call_${round}_${callIndex}`,
|
||||||
|
name: block.name ?? "unknown_tool",
|
||||||
|
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (roundText) {
|
||||||
|
yield { type: "delta", text: roundText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: assistantContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolResultBlocks: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: toolResultBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
332
server/src/llm/protocols/responses-api.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import {
|
||||||
|
appendDanglingToolIntentCorrection,
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
getUnstreamedText,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolAwareUsage,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
function toResponsesTools(tools: any[]) {
|
||||||
|
return tools.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return tool;
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
parameters: tool.function.parameters,
|
||||||
|
strict: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
parts.push({
|
||||||
|
type: "input_image",
|
||||||
|
image_url: attachment.dataUrl,
|
||||||
|
detail: "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ type: "input_text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ type: "input_text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||||
|
return parts[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInputMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role,
|
||||||
|
content: toContentParts(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
|
const normalized = messages.map((message) => buildInputMessage(message));
|
||||||
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
if (!usage) return false;
|
||||||
|
acc.inputTokens += usage.input_tokens ?? 0;
|
||||||
|
acc.outputTokens += usage.output_tokens ?? 0;
|
||||||
|
acc.totalTokens += usage.total_tokens ?? 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputItems(response: any) {
|
||||||
|
return Array.isArray(response?.output) ? response.output : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any, fallback = "") {
|
||||||
|
if (typeof response?.output_text === "string") return response.output_text;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const item of getOutputItems(response)) {
|
||||||
|
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||||
|
for (const content of item.content) {
|
||||||
|
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||||
|
parts.push(content.text);
|
||||||
|
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||||
|
parts.push(content.refusal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("") || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureMessage(response: any) {
|
||||||
|
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||||
|
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||||
|
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||||
|
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return outputItems
|
||||||
|
.filter((item) => item?.type === "function_call")
|
||||||
|
.map((call: any, index: number) => ({
|
||||||
|
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call.name ?? "unknown_tool",
|
||||||
|
arguments: call.arguments ?? "{}",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithResponsesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const response = await params.client.responses.create({
|
||||||
|
model: params.model,
|
||||||
|
input,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_output_tokens: params.maxTokens,
|
||||||
|
tools: toResponsesTools(enabledTools),
|
||||||
|
tool_choice: "auto",
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
store: true,
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const failureMessage = getFailureMessage(response);
|
||||||
|
if (failureMessage) {
|
||||||
|
throw new Error(failureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputItems = getOutputItems(response);
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(outputItems, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(response);
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(input, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
input.push(...outputItems);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
|
||||||
|
input.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithResponsesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const stream = await params.client.responses.create({
|
||||||
|
model: params.model,
|
||||||
|
input,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_output_tokens: params.maxTokens,
|
||||||
|
tools: toResponsesTools(enabledTools),
|
||||||
|
tool_choice: "auto",
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
store: true,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
let roundText = "";
|
||||||
|
let streamedRoundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
let canStreamRoundText = false;
|
||||||
|
let completedResponse: any | null = null;
|
||||||
|
const completedOutputItems: any[] = [];
|
||||||
|
|
||||||
|
for await (const event of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(event);
|
||||||
|
|
||||||
|
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||||
|
roundText += event.delta;
|
||||||
|
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||||
|
streamedRoundText += event.delta;
|
||||||
|
yield { type: "delta", text: event.delta };
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||||
|
if (event.item.type === "function_call") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
canStreamRoundText = false;
|
||||||
|
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||||
|
canStreamRoundText = true;
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||||
|
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||||
|
if (event.item.type === "function_call") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
canStreamRoundText = false;
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.completed") {
|
||||||
|
completedResponse = event.response;
|
||||||
|
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||||
|
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||||
|
completedResponse = event.response;
|
||||||
|
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||||
|
} else if (event?.type === "error") {
|
||||||
|
throw new Error(event.message ?? "Responses stream failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureMessage = getFailureMessage(completedResponse);
|
||||||
|
if (failureMessage) {
|
||||||
|
throw new Error(failureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputItems = getOutputItems(completedResponse);
|
||||||
|
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(responseOutputItems, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(completedResponse, roundText);
|
||||||
|
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(input, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
input.push(...responseOutputItems);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
input.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
217
server/src/llm/provider-adapters.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
normalizeEnabledChatTools,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
} from "./chat-tools.js";
|
||||||
|
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||||
|
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||||
|
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
|
import type { ChatMessage, Provider } from "./types.js";
|
||||||
|
|
||||||
|
type ProviderAdapterParams = {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
logContext?: ToolAwareCompletionParams["logContext"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderChatAdapter = {
|
||||||
|
provider: Provider;
|
||||||
|
complete(params: ProviderAdapterParams): Promise<ToolAwareCompletionResult>;
|
||||||
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||||
|
|
||||||
|
type ChatProtocol = {
|
||||||
|
id: ChatProtocolId;
|
||||||
|
complete(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult>;
|
||||||
|
stream(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelCatalogSpec = {
|
||||||
|
enabled?: () => boolean;
|
||||||
|
fetchModels(client: any): Promise<string[]>;
|
||||||
|
fallbackModels?: () => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderBackendSpec = {
|
||||||
|
createClient: () => any;
|
||||||
|
plainProtocol: ChatProtocol;
|
||||||
|
toolProtocol?: ChatProtocol;
|
||||||
|
managedTools?: boolean;
|
||||||
|
modelCatalog?: ModelCatalogSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatCompletionsProtocol: ChatProtocol = {
|
||||||
|
id: "chat-completions",
|
||||||
|
complete: completeWithChatCompletionsApi,
|
||||||
|
stream: streamWithChatCompletionsApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesProtocol: ChatProtocol = {
|
||||||
|
id: "messages",
|
||||||
|
complete: completeWithMessagesApi,
|
||||||
|
stream: streamWithMessagesApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsesProtocol: ChatProtocol = {
|
||||||
|
id: "responses",
|
||||||
|
complete: completeWithResponsesApi,
|
||||||
|
stream: streamWithResponsesApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqSorted(values: string[]) {
|
||||||
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelIdsFromListResponse(page: any) {
|
||||||
|
return Array.isArray(page?.data)
|
||||||
|
? page.data.map((model: any) => model?.id).filter((id: unknown): id is string => typeof id === "string")
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyResponsesApiModel(model: string) {
|
||||||
|
const id = model.toLowerCase();
|
||||||
|
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||||
|
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||||
|
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||||
|
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||||
|
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
model: params.model,
|
||||||
|
messages: params.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: params.userLocation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
maxTokens: params.maxTokens,
|
||||||
|
logContext: params.logContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChatProtocol(spec: ProviderBackendSpec, params: Pick<ProviderAdapterParams, "enabledTools">) {
|
||||||
|
const enabledTools = normalizeEnabledChatTools(params.enabledTools);
|
||||||
|
const useManagedTools = spec.managedTools === true && spec.toolProtocol && enabledTools.length > 0;
|
||||||
|
return {
|
||||||
|
protocol: useManagedTools ? spec.toolProtocol! : spec.plainProtocol,
|
||||||
|
enabledTools: useManagedTools ? enabledTools : [],
|
||||||
|
managedTools: Boolean(useManagedTools),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProviderChatAdapter(provider: Provider, spec: ProviderBackendSpec): ProviderChatAdapter {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
complete(params) {
|
||||||
|
const selected = selectChatProtocol(spec, params);
|
||||||
|
return selected.protocol.complete(withClient(params, spec.createClient(), selected.enabledTools));
|
||||||
|
},
|
||||||
|
stream(params) {
|
||||||
|
const selected = selectChatProtocol(spec, params);
|
||||||
|
return selected.protocol.stream(withClient(params, spec.createClient(), selected.enabledTools));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||||
|
openai: {
|
||||||
|
createClient: openaiClient,
|
||||||
|
plainProtocol: chatCompletionsProtocol,
|
||||||
|
toolProtocol: responsesProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
return modelIdsFromListResponse(page).filter(isLikelyResponsesApiModel);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
createClient: anthropicClient,
|
||||||
|
plainProtocol: messagesProtocol,
|
||||||
|
toolProtocol: messagesProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list({ limit: 200 });
|
||||||
|
return modelIdsFromListResponse(page);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xai: {
|
||||||
|
createClient: xaiClient,
|
||||||
|
plainProtocol: chatCompletionsProtocol,
|
||||||
|
toolProtocol: chatCompletionsProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
return modelIdsFromListResponse(page);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"hermes-agent": {
|
||||||
|
createClient: hermesAgentClient,
|
||||||
|
plainProtocol: chatCompletionsProtocol,
|
||||||
|
managedTools: false,
|
||||||
|
modelCatalog: {
|
||||||
|
enabled: isHermesAgentConfigured,
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
const models = modelIdsFromListResponse(page);
|
||||||
|
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||||
|
return models;
|
||||||
|
},
|
||||||
|
fallbackModels() {
|
||||||
|
return env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerChatAdapters: Record<Provider, ProviderChatAdapter> = Object.fromEntries(
|
||||||
|
Object.entries(backendSpecs).map(([provider, spec]) => [provider, createProviderChatAdapter(provider as Provider, spec)])
|
||||||
|
) as Record<Provider, ProviderChatAdapter>;
|
||||||
|
|
||||||
|
export function getProviderChatAdapter(provider: Provider) {
|
||||||
|
return providerChatAdapters[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeProviderChatBackend(provider: Provider, enabledTools?: string[]) {
|
||||||
|
const selected = selectChatProtocol(backendSpecs[provider], { enabledTools });
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
protocol: selected.protocol.id,
|
||||||
|
managedTools: selected.managedTools,
|
||||||
|
enabledTools: selected.enabledTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listModelCatalogProviders(): Provider[] {
|
||||||
|
return (Object.entries(backendSpecs) as [Provider, ProviderBackendSpec][])
|
||||||
|
.filter(([, spec]) => {
|
||||||
|
const catalog = spec.modelCatalog;
|
||||||
|
return catalog !== undefined && catalog.enabled?.() !== false;
|
||||||
|
})
|
||||||
|
.map(([provider]) => provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||||
|
const spec = backendSpecs[provider].modelCatalog;
|
||||||
|
if (!spec) return [];
|
||||||
|
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||||
|
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
|
||||||
|
}
|
||||||
44
server/src/llm/provider-ids.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
|
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||||
|
|
||||||
|
const apiToPrismaProvider = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
xai: "xai",
|
||||||
|
"hermes-agent": "hermes_agent",
|
||||||
|
} as const satisfies Record<Provider, PrismaProvider>;
|
||||||
|
|
||||||
|
const prismaToApiProvider = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
xai: "xai",
|
||||||
|
hermes_agent: "hermes-agent",
|
||||||
|
"hermes-agent": "hermes-agent",
|
||||||
|
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||||
|
|
||||||
|
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||||
|
return apiToPrismaProvider[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||||
|
if (provider === null || provider === undefined) return null;
|
||||||
|
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||||
|
const next: Record<string, any> = { ...value };
|
||||||
|
if ("initiatedProvider" in next) {
|
||||||
|
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
|
||||||
|
}
|
||||||
|
if ("lastUsedProvider" in next) {
|
||||||
|
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
|
||||||
|
}
|
||||||
|
if ("provider" in next) {
|
||||||
|
next.provider = fromPrismaProvider(next.provider);
|
||||||
|
}
|
||||||
|
if (Array.isArray(next.calls)) {
|
||||||
|
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
@@ -13,6 +13,18 @@ export function xaiClient() {
|
|||||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
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,14 +1,24 @@
|
|||||||
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 {
|
||||||
import { buildToolLogMessageData, runToolAwareOpenAIChatStream, type ToolExecutionEvent } from "./chat-tools.js";
|
buildToolLogMessageData,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "./chat-tools.js";
|
||||||
|
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||||
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
|
type StreamUsage = {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
|
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
|
||||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||||
| { type: "delta"; text: string }
|
| { type: "delta"; text: string }
|
||||||
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
|
| { type: "done"; text: string; usage?: StreamUsage }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
function getChatIdOrCreate(chatId?: string) {
|
function getChatIdOrCreate(chatId?: string) {
|
||||||
@@ -18,154 +28,124 @@ function getChatIdOrCreate(chatId?: string) {
|
|||||||
|
|
||||||
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
|
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const chatId = await getChatIdOrCreate(req.chatId);
|
const shouldPersist = req.persist !== false;
|
||||||
|
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
|
||||||
|
|
||||||
const call = await prisma.llmCall.create({
|
const call =
|
||||||
data: {
|
shouldPersist && chatId
|
||||||
chatId,
|
? await prisma.llmCall.create({
|
||||||
provider: req.provider as any,
|
data: {
|
||||||
model: req.model,
|
chatId,
|
||||||
request: req as any,
|
provider: toPrismaProvider(req.provider) as any,
|
||||||
},
|
model: req.model,
|
||||||
select: { id: true },
|
request: req as any,
|
||||||
});
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
await prisma.$transaction([
|
if (shouldPersist && chatId) {
|
||||||
prisma.chat.update({
|
await prisma.$transaction([
|
||||||
where: { id: chatId },
|
prisma.chat.update({
|
||||||
data: {
|
where: { id: chatId },
|
||||||
lastUsedProvider: req.provider as any,
|
data: {
|
||||||
lastUsedModel: req.model,
|
lastUsedProvider: toPrismaProvider(req.provider) as any,
|
||||||
},
|
lastUsedModel: req.model,
|
||||||
}),
|
},
|
||||||
prisma.chat.updateMany({
|
}),
|
||||||
where: { id: chatId, initiatedProvider: null },
|
prisma.chat.updateMany({
|
||||||
data: {
|
where: { id: chatId, initiatedProvider: null },
|
||||||
initiatedProvider: req.provider as any,
|
data: {
|
||||||
initiatedModel: req.model,
|
initiatedProvider: toPrismaProvider(req.provider) as any,
|
||||||
},
|
initiatedModel: req.model,
|
||||||
}),
|
},
|
||||||
]);
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
|
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
let usage: StreamEvent extends any ? any : never;
|
let usage: StreamUsage | undefined;
|
||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
const adapter = getProviderChatAdapter(req.provider);
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
const streamEvents = adapter.stream({
|
||||||
for await (const ev of runToolAwareOpenAIChatStream({
|
model: req.model,
|
||||||
client,
|
messages: req.messages,
|
||||||
|
enabledTools: req.enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
chatId: chatId ?? undefined,
|
||||||
temperature: req.temperature,
|
},
|
||||||
maxTokens: req.maxTokens,
|
});
|
||||||
logContext: {
|
|
||||||
provider: req.provider,
|
|
||||||
model: req.model,
|
|
||||||
chatId,
|
|
||||||
},
|
|
||||||
})) {
|
|
||||||
if (ev.type === "delta") {
|
|
||||||
text += ev.text;
|
|
||||||
yield { type: "delta", text: ev.text };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.type === "tool_call") {
|
for await (const ev of streamEvents) {
|
||||||
toolMessages.push(buildToolLogMessageData(chatId, ev.event));
|
if (ev.type === "delta") {
|
||||||
yield { type: "tool_call", event: ev.event };
|
text += ev.text;
|
||||||
continue;
|
yield { type: "delta", text: ev.text };
|
||||||
}
|
continue;
|
||||||
|
|
||||||
raw = ev.result.raw;
|
|
||||||
usage = ev.result.usage;
|
|
||||||
text = ev.result.text;
|
|
||||||
}
|
}
|
||||||
} else if (req.provider === "anthropic") {
|
|
||||||
const client = anthropicClient();
|
|
||||||
|
|
||||||
const system = req.messages.find((m) => m.role === "system")?.content;
|
if (ev.type === "tool_call") {
|
||||||
const msgs = req.messages
|
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||||
.filter((m) => m.role !== "system")
|
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||||
.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
|
await prisma.message.create({
|
||||||
|
data: {
|
||||||
const stream = await client.messages.create({
|
chatId: toolMessage.chatId,
|
||||||
model: req.model,
|
role: toolMessage.role as any,
|
||||||
system,
|
content: toolMessage.content,
|
||||||
max_tokens: req.maxTokens ?? 1024,
|
name: toolMessage.name,
|
||||||
temperature: req.temperature,
|
metadata: toolMessage.metadata as any,
|
||||||
messages: msgs as any,
|
},
|
||||||
stream: true,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
|
||||||
// Anthropic streaming events include content_block_delta with text_delta
|
|
||||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
|
||||||
const delta = ev.delta.text ?? "";
|
|
||||||
if (delta) {
|
|
||||||
text += delta;
|
|
||||||
yield { type: "delta", text: delta };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// capture usage if present on message_delta
|
yield { type: "tool_call", event: ev.event };
|
||||||
if (ev?.type === "message_delta" && ev?.usage) {
|
continue;
|
||||||
usage = {
|
|
||||||
inputTokens: ev.usage.input_tokens,
|
|
||||||
outputTokens: ev.usage.output_tokens,
|
|
||||||
totalTokens:
|
|
||||||
(ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// some streams end with message_stop
|
|
||||||
}
|
}
|
||||||
raw = { streamed: true, provider: "anthropic" };
|
|
||||||
} else {
|
raw = ev.result.raw;
|
||||||
throw new Error(`unknown provider: ${req.provider}`);
|
usage = ev.result.usage;
|
||||||
|
text = ev.result.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
if (shouldPersist && chatId && call) {
|
||||||
if (toolMessages.length) {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.message.createMany({
|
await tx.message.create({
|
||||||
data: toolMessages.map((message) => ({
|
data: { chatId, role: "assistant" as any, content: text },
|
||||||
chatId: message.chatId,
|
});
|
||||||
role: message.role as any,
|
await tx.llmCall.update({
|
||||||
content: message.content,
|
where: { id: call.id },
|
||||||
name: message.name,
|
data: {
|
||||||
metadata: message.metadata as any,
|
response: raw as any,
|
||||||
})),
|
latencyMs,
|
||||||
|
inputTokens: usage?.inputTokens,
|
||||||
|
outputTokens: usage?.outputTokens,
|
||||||
|
totalTokens: usage?.totalTokens,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
await tx.message.create({
|
|
||||||
data: { chatId, role: "assistant" as any, content: text },
|
|
||||||
});
|
});
|
||||||
await tx.llmCall.update({
|
}
|
||||||
where: { id: call.id },
|
|
||||||
data: {
|
|
||||||
response: raw as any,
|
|
||||||
latencyMs,
|
|
||||||
inputTokens: usage?.inputTokens,
|
|
||||||
outputTokens: usage?.outputTokens,
|
|
||||||
totalTokens: usage?.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
yield { type: "done", text, usage };
|
yield { type: "done", text, usage };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
await prisma.llmCall.update({
|
if (shouldPersist && call) {
|
||||||
where: { id: call.id },
|
await prisma.llmCall.update({
|
||||||
data: {
|
where: { id: call.id },
|
||||||
error: e?.message ?? String(e),
|
data: {
|
||||||
latencyMs,
|
error: e?.message ?? String(e),
|
||||||
},
|
latencyMs,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
yield { type: "error", message: e?.message ?? String(e) };
|
yield { type: "error", message: e?.message ?? String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||||
|
|
||||||
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
|
export type ChatImageAttachment = {
|
||||||
|
kind: "image";
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: "image/png" | "image/jpeg";
|
||||||
|
sizeBytes: number;
|
||||||
|
dataUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatTextAttachment = {
|
||||||
|
kind: "text";
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
text: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MultiplexRequest = {
|
export type MultiplexRequest = {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
|
persist?: boolean;
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|||||||
1086
server/src/routes.ts
29
server/src/search-cache.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||||
|
const normalized = value?.trim().toLowerCase() ?? "";
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||||
|
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFreshSearchCacheHit(
|
||||||
|
candidate: {
|
||||||
|
updatedAt: Date | string;
|
||||||
|
resultCount: number;
|
||||||
|
answerText?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
now = new Date(),
|
||||||
|
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
if (candidate.isActive) return false;
|
||||||
|
if (!hasReusableSearchPayload(candidate)) return false;
|
||||||
|
|
||||||
|
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||||
|
if (!Number.isFinite(updatedAtMs)) return false;
|
||||||
|
|
||||||
|
return now.getTime() - updatedAtMs <= ttlMs;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
|
||||||
const SEARXNG_TIMEOUT_MS = 12_000;
|
const SEARXNG_TIMEOUT_MS = 12_000;
|
||||||
@@ -106,10 +107,7 @@ async function fetchSearxng(url: URL, accept: string) {
|
|||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: buildBrowserLikeRequestHeaders(accept),
|
||||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
|
||||||
Accept: accept,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
34
server/tests/active-streams.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ActiveSseStream, type SseStreamEvent } from "../src/active-streams.js";
|
||||||
|
|
||||||
|
test("ActiveSseStream replays buffered events to late subscribers", () => {
|
||||||
|
const stream = new ActiveSseStream();
|
||||||
|
stream.emit("delta", { text: "hel" });
|
||||||
|
stream.emit("delta", { text: "lo" });
|
||||||
|
|
||||||
|
const events: SseStreamEvent[] = [];
|
||||||
|
const unsubscribe = stream.subscribe((event) => events.push(event));
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{ event: "delta", data: { text: "hel" } },
|
||||||
|
{ event: "delta", data: { text: "lo" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ActiveSseStream replays terminal events after completion", async () => {
|
||||||
|
const stream = new ActiveSseStream();
|
||||||
|
stream.emit("delta", { text: "done" });
|
||||||
|
stream.complete({ event: "done", data: { text: "done" } });
|
||||||
|
await stream.done;
|
||||||
|
|
||||||
|
const events: SseStreamEvent[] = [];
|
||||||
|
stream.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
assert.equal(stream.isCompleted, true);
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{ event: "delta", data: { text: "done" } },
|
||||||
|
{ event: "done", data: { text: "done" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
473
server/tests/chat-tools-streaming.test.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
|
||||||
|
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
|
||||||
|
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
|
||||||
|
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
|
||||||
|
|
||||||
|
async function* streamFrom(events: any[]) {
|
||||||
|
for (const event of events) {
|
||||||
|
await Promise.resolve();
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
|
||||||
|
const events: ToolAwareStreamingEvent[] = [];
|
||||||
|
for await (const event of iterable) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Responses API stream emits text deltas as they arrive", async () => {
|
||||||
|
const outputMessage = {
|
||||||
|
id: "msg_1",
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text: "Hello" }],
|
||||||
|
};
|
||||||
|
const client = {
|
||||||
|
responses: {
|
||||||
|
create: async () =>
|
||||||
|
streamFrom([
|
||||||
|
{ type: "response.output_item.added", item: { ...outputMessage, content: [] }, output_index: 0 },
|
||||||
|
{ type: "response.output_text.delta", delta: "Hel", output_index: 0, content_index: 0 },
|
||||||
|
{ type: "response.output_text.delta", delta: "lo", output_index: 0, content_index: 0 },
|
||||||
|
{ type: "response.output_item.done", item: outputMessage, output_index: 0 },
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
status: "completed",
|
||||||
|
output_text: "Hello",
|
||||||
|
output: [outputMessage],
|
||||||
|
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithResponsesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "gpt-test",
|
||||||
|
messages: [{ role: "user", content: "Say hello" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["delta", "delta", "done"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
events.filter((event) => event.type === "delta").map((event) => event.text),
|
||||||
|
["Hel", "lo"]
|
||||||
|
);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Chat Completions API stream emits text deltas as they arrive", async () => {
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async () =>
|
||||||
|
streamFrom([
|
||||||
|
{ choices: [{ delta: { role: "assistant" } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hel" } }] },
|
||||||
|
{ choices: [{ delta: { content: "lo" } }] },
|
||||||
|
{
|
||||||
|
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithChatCompletionsApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "grok-test",
|
||||||
|
messages: [{ role: "user", content: "Say hello" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["delta", "delta", "done"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
events.filter((event) => event.type === "delta").map((event) => event.text),
|
||||||
|
["Hel", "lo"]
|
||||||
|
);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Hi" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithChatCompletionsApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "hermes-agent",
|
||||||
|
messages: [{ role: "user", content: "Say hi" }],
|
||||||
|
enabledTools: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(requestBody.model, "hermes-agent");
|
||||||
|
assert.equal(requestBody.stream, true);
|
||||||
|
assert.equal("tools" in requestBody, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["delta", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetch_url sends browser-like navigation headers", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ input, init });
|
||||||
|
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let requestCount = 0;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async () => {
|
||||||
|
requestCount += 1;
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_1",
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "fetch_url",
|
||||||
|
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
choices: [{ message: { content: "Fetched" } }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await completeWithChatCompletionsApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "grok-test",
|
||||||
|
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.text, "Fetched");
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
|
||||||
|
assert.deepEqual(fetchCalls[0]?.init?.headers, {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
});
|
||||||
|
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ input, init });
|
||||||
|
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBodies: any[] = [];
|
||||||
|
const client = {
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBodies.push(body);
|
||||||
|
if (requestBodies.length === 1) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_1",
|
||||||
|
name: "fetch_url",
|
||||||
|
input: { url: "https://example.com/article" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 3, output_tokens: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Fetched" }],
|
||||||
|
usage: { input_tokens: 5, output_tokens: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await completeWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-test",
|
||||||
|
messages: [{ role: "user", content: "Fetch the article" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.text, "Fetched");
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
|
||||||
|
assert.equal(requestBodies.length, 2);
|
||||||
|
assert.equal(requestBodies[0]?.model, "claude-test");
|
||||||
|
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
|
||||||
|
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
|
||||||
|
assert.equal(fetchTool?.input_schema?.type, "object");
|
||||||
|
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
|
||||||
|
|
||||||
|
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||||
|
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
|
||||||
|
assert.equal(secondMessages.at(-1)?.role, "user");
|
||||||
|
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||||
|
assert.equal(toolResult?.type, "tool_result");
|
||||||
|
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||||
|
assert.equal(toolResult?.is_error, false);
|
||||||
|
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
|
||||||
|
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||||
|
assert.equal(result.usage?.inputTokens, 8);
|
||||||
|
assert.equal(result.usage?.outputTokens, 3);
|
||||||
|
assert.equal(result.usage?.totalTokens, 11);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async () => {
|
||||||
|
requestCount += 1;
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id: "call_1",
|
||||||
|
function: {
|
||||||
|
name: "unknown_tool",
|
||||||
|
arguments: "{\"query\":\"current weather\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Done" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithChatCompletionsApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "grok-test",
|
||||||
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["tool_call", "tool_call", "delta", "done"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||||
|
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[0]?.status, "initiated");
|
||||||
|
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||||
|
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||||
|
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[1]?.status, "failed");
|
||||||
|
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||||
|
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Messages API stream emits initiated and terminal tool call updates", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
const requestBodies: any[] = [];
|
||||||
|
const client = {
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestCount += 1;
|
||||||
|
requestBodies.push(body);
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 3, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "I'll check that." },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 1,
|
||||||
|
content_block: {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_1",
|
||||||
|
name: "unknown_tool",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 1,
|
||||||
|
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 1 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 2 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 4, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Done" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-test",
|
||||||
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["tool_call", "tool_call", "delta", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(requestBodies[0]?.stream, true);
|
||||||
|
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
|
||||||
|
|
||||||
|
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||||
|
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
|
||||||
|
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
|
||||||
|
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||||
|
assert.equal(toolResult?.type, "tool_result");
|
||||||
|
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||||
|
assert.equal(toolResult?.is_error, true);
|
||||||
|
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
|
||||||
|
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||||
|
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(toolEvents[0]?.status, "initiated");
|
||||||
|
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(toolEvents[1]?.status, "failed");
|
||||||
|
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
|
||||||
|
});
|
||||||
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSystemPromptAugmentation, buildTopLevelSystemPrompt } from "../src/llm/message-content.js";
|
||||||
|
|
||||||
|
test("system prompt augmentation includes date and default location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system prompt augmentation uses provided user location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("top-level system prompt includes runtime context with existing system messages", () => {
|
||||||
|
const prompt = buildTopLevelSystemPrompt(
|
||||||
|
[{ role: "system", content: "Use concise answers." }],
|
||||||
|
"Los Angeles, CA"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||||
|
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||||
|
assert.match(prompt, /Use concise answers\./);
|
||||||
|
});
|
||||||
36
server/tests/provider-adapters.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||||
|
|
||||||
|
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||||
|
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "chat-completions",
|
||||||
|
managedTools: false,
|
||||||
|
enabledTools: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "responses",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
|
||||||
|
provider: "anthropic",
|
||||||
|
protocol: "messages",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
|
||||||
|
provider: "xai",
|
||||||
|
protocol: "chat-completions",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||||
|
provider: "hermes-agent",
|
||||||
|
protocol: "chat-completions",
|
||||||
|
managedTools: false,
|
||||||
|
enabledTools: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
12
server/tests/provider-ids.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
|
||||||
|
|
||||||
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||||
|
initiatedProvider: "hermes-agent",
|
||||||
|
lastUsedProvider: "xai",
|
||||||
|
});
|
||||||
|
});
|
||||||
25
server/tests/search-cache.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||||
|
|
||||||
|
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||||
|
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||||
|
assert.equal(normalizeSearchQuery(" "), null);
|
||||||
|
assert.equal(normalizeSearchQuery(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||||
|
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
|
|||||||
|
|
||||||
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
- `SYBIL_TUI_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
@@ -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;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
|
|||||||
- Composer adapts to the active item:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||||
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
||||||
|
- Keyboard shortcuts:
|
||||||
|
- `Cmd/Ctrl+J`: start a new chat.
|
||||||
|
- `Shift+Cmd/Ctrl+J`: start a new search.
|
||||||
|
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
|
||||||
|
|
||||||
Client API contract docs:
|
Client API contract docs:
|
||||||
- `../docs/api/rest.md`
|
- `../docs/api/rest.md`
|
||||||
|
|||||||
@@ -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/StalinistOne-Regular.ttf
Normal file
BIN
web/public/character-busy.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/public/character-idle.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
web/public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
web/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
@@ -1,9 +1,32 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Sybil",
|
"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
@@ -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));
|
||||||
|
});
|
||||||
2724
web/src/App.tsx
@@ -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">
|
||||||
|
|||||||
103
web/src/components/chat/chat-attachment-list.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { FileText, Image as ImageIcon, X } from "lucide-preact";
|
||||||
|
import type { ChatAttachment } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attachments: ChatAttachment[];
|
||||||
|
tone?: "composer" | "user" | "assistant";
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTextPreview(value: string) {
|
||||||
|
const normalized = value.replace(/\r/g, "").trim();
|
||||||
|
if (!normalized) return "(empty file)";
|
||||||
|
return normalized.length <= 280 ? normalized : `${normalized.slice(0, 280).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSurfaceClasses(tone: Props["tone"]) {
|
||||||
|
if (tone === "user") {
|
||||||
|
return "border-white/12 bg-black/16 text-fuchsia-50";
|
||||||
|
}
|
||||||
|
if (tone === "assistant") {
|
||||||
|
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
|
||||||
|
}
|
||||||
|
return "border-violet-300/18 bg-background/40 text-violet-50";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatAttachmentList({ attachments, tone = "composer", onRemove }: Props) {
|
||||||
|
if (!attachments.length) return null;
|
||||||
|
|
||||||
|
const surfaceClasses = getSurfaceClasses(tone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((attachment) => {
|
||||||
|
const isImage = attachment.kind === "image";
|
||||||
|
return (
|
||||||
|
<div key={attachment.id} className={cn("overflow-hidden rounded-xl border", surfaceClasses)}>
|
||||||
|
{isImage ? (
|
||||||
|
<div className="grid gap-0 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
|
||||||
|
<div className="border-b border-white/10 bg-black/10 md:border-b-0 md:border-r">
|
||||||
|
<img src={attachment.dataUrl} alt={attachment.filename} className="block max-h-56 w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
|
||||||
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{attachment.filename}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{attachment.mimeType}</p>
|
||||||
|
</div>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
|
||||||
|
onClick={() => onRemove(attachment.id)}
|
||||||
|
aria-label={`Remove ${attachment.filename}`}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{attachment.filename}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{attachment.mimeType}
|
||||||
|
{attachment.truncated ? " · truncated" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
|
||||||
|
onClick={() => onRemove(attachment.id)}
|
||||||
|
aria-label={`Remove ${attachment.filename}`}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto rounded-lg border border-white/8 bg-black/16 p-3 text-xs leading-5 text-inherit whitespace-pre-wrap">
|
||||||
|
{getTextPreview(attachment.text)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import type { ComponentChildren, JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Message } from "@/lib/api";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
import { Globe2, Link2, Wrench } from "lucide-preact";
|
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -11,9 +14,16 @@ type Props = {
|
|||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
status?: "completed" | "failed";
|
status?: "initiated" | "completed" | "failed";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: string | null;
|
||||||
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
@@ -25,10 +35,26 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
|||||||
|
|
||||||
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
||||||
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
||||||
|
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
|
||||||
|
return `Tool failed: ${metadata.error.trim()}`;
|
||||||
|
}
|
||||||
|
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
|
||||||
|
if (message.content.trim()) return message.content.trim();
|
||||||
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
||||||
return `Ran tool '${toolName}'.`;
|
return `Ran tool '${toolName}'.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
|
||||||
|
const raw = metadata.toolName?.trim() || message.name?.trim();
|
||||||
|
if (!raw) return "Tool call";
|
||||||
|
return raw
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function getToolIconName(toolName: string | null | undefined) {
|
function getToolIconName(toolName: string | null | undefined) {
|
||||||
const lowered = toolName?.toLowerCase() ?? "";
|
const lowered = toolName?.toLowerCase() ?? "";
|
||||||
if (lowered.includes("search")) return "search";
|
if (lowered.includes("search")) return "search";
|
||||||
@@ -36,60 +62,425 @@ function getToolIconName(toolName: string | null | undefined) {
|
|||||||
return "generic";
|
return "generic";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationMs: unknown) {
|
||||||
|
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
|
||||||
|
return `${Math.round(durationMs)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||||
|
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
|
||||||
|
if (!value) return null;
|
||||||
|
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||||
|
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
|
||||||
|
type ToolStackStyle = JSX.CSSProperties & {
|
||||||
|
"--tool-stack-x"?: string;
|
||||||
|
"--tool-stack-y"?: string;
|
||||||
|
"--tool-stack-z"?: string;
|
||||||
|
"--tool-stack-scale"?: string;
|
||||||
|
"--tool-stack-opacity"?: string;
|
||||||
|
"--tool-stack-delay"?: string;
|
||||||
|
"--tool-stack-from-transform"?: string;
|
||||||
|
"--tool-stack-to-transform"?: string;
|
||||||
|
"--tool-stack-from-opacity"?: string;
|
||||||
|
"--tool-stack-to-opacity"?: string;
|
||||||
|
};
|
||||||
|
type ToolStackContainerStyle = JSX.CSSProperties & {
|
||||||
|
"--tool-stack-from-height"?: string;
|
||||||
|
"--tool-stack-to-height"?: string;
|
||||||
|
};
|
||||||
|
type ToolStackMotionDirection = "expand" | "collapse" | null;
|
||||||
|
|
||||||
|
const COLLAPSED_TOOL_STACK_LIMIT = 4;
|
||||||
|
const TOOL_STACK_CARD_HEIGHT = 62;
|
||||||
|
const TOOL_STACK_CARD_GAP = 10;
|
||||||
|
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
|
||||||
|
|
||||||
|
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
|
||||||
|
if (metadata.status === "failed") return "failed";
|
||||||
|
if (metadata.status === "initiated") return "initiated";
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
|
||||||
|
return [
|
||||||
|
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||||
|
formatDuration(metadata.durationMs),
|
||||||
|
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" • ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageRenderItems(messages: Message[]) {
|
||||||
|
const items: MessageRenderItem[] = [];
|
||||||
|
let toolRun: Message[] = [];
|
||||||
|
|
||||||
|
const flushToolRun = () => {
|
||||||
|
if (!toolRun.length) return;
|
||||||
|
if (toolRun.length === 1) {
|
||||||
|
items.push({ kind: "message", message: toolRun[0] });
|
||||||
|
} else {
|
||||||
|
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
|
||||||
|
}
|
||||||
|
toolRun = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
|
||||||
|
toolRun.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushToolRun();
|
||||||
|
items.push({ kind: "message", message });
|
||||||
|
}
|
||||||
|
|
||||||
|
flushToolRun();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCallMessageIDs(messages: Message[]) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "tool" && asToolLogMetadata(message.metadata)) ids.add(message.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolStackHeight(messageCount: number, expanded: boolean) {
|
||||||
|
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||||
|
return expanded
|
||||||
|
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
|
||||||
|
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
|
||||||
|
const collapsedHeight = getToolStackHeight(messageCount, false);
|
||||||
|
const expandedHeight = getToolStackHeight(messageCount, true);
|
||||||
|
const targetHeight = expanded ? expandedHeight : collapsedHeight;
|
||||||
|
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--tool-stack-from-height": fromHeight,
|
||||||
|
"--tool-stack-to-height": targetHeight,
|
||||||
|
height: targetHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpandedToolLayout(index: number, messageCount: number) {
|
||||||
|
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
|
||||||
|
return {
|
||||||
|
opacity: "1",
|
||||||
|
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
|
||||||
|
x: "0px",
|
||||||
|
y,
|
||||||
|
z: "0px",
|
||||||
|
scale: "1",
|
||||||
|
zIndex: messageCount - index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollapsedToolLayout(index: number, messageCount: number) {
|
||||||
|
const depth = messageCount - index - 1;
|
||||||
|
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
|
||||||
|
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||||
|
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||||
|
const x = `${visibleDepth * 11}px`;
|
||||||
|
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
|
||||||
|
const z = `${visibleDepth * -36}px`;
|
||||||
|
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
|
||||||
|
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity,
|
||||||
|
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
scale,
|
||||||
|
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
|
||||||
|
const expandedLayout = getExpandedToolLayout(index, messageCount);
|
||||||
|
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
|
||||||
|
const targetLayout = expanded ? expandedLayout : collapsedLayout;
|
||||||
|
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--tool-stack-x": targetLayout.x,
|
||||||
|
"--tool-stack-y": targetLayout.y,
|
||||||
|
"--tool-stack-z": targetLayout.z,
|
||||||
|
"--tool-stack-scale": targetLayout.scale,
|
||||||
|
"--tool-stack-opacity": targetLayout.opacity,
|
||||||
|
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
|
||||||
|
"--tool-stack-from-transform": fromLayout.transform,
|
||||||
|
"--tool-stack-to-transform": targetLayout.transform,
|
||||||
|
"--tool-stack-from-opacity": fromLayout.opacity,
|
||||||
|
"--tool-stack-to-opacity": targetLayout.opacity,
|
||||||
|
opacity: targetLayout.opacity,
|
||||||
|
transform: targetLayout.transform,
|
||||||
|
zIndex: targetLayout.zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallCard({
|
||||||
|
message,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
message: Message;
|
||||||
|
className?: string;
|
||||||
|
style?: JSX.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||||
|
if (!toolLogMetadata) return null;
|
||||||
|
|
||||||
|
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||||
|
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||||
|
const toolState = getToolVisualState(toolLogMetadata);
|
||||||
|
const isFailed = toolState === "failed";
|
||||||
|
const isInitiated = toolState === "initiated";
|
||||||
|
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||||
|
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||||
|
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||||
|
isFailed
|
||||||
|
? "border-rose-400/44 bg-[linear-gradient(90deg,hsl(350_64%_20%),hsl(342_58%_9%))]"
|
||||||
|
: isInitiated
|
||||||
|
? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]"
|
||||||
|
: "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||||
|
isFailed
|
||||||
|
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||||
|
: isInitiated
|
||||||
|
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||||
|
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 space-y-1">
|
||||||
|
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
|
||||||
|
<span className="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" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||||
|
{toolLabel}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallStackCardSurface({
|
||||||
|
messageID,
|
||||||
|
animateEntry,
|
||||||
|
isHidden,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
messageID: string;
|
||||||
|
animateEntry: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
children: ComponentChildren;
|
||||||
|
}) {
|
||||||
|
const [shouldAnimateEntry] = useState(() => animateEntry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("tool-call-stack-card-surface", shouldAnimateEntry && !isHidden && "tool-call-stack-card-enter")}
|
||||||
|
data-tool-stack-card-id={messageID}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallStack({
|
||||||
|
groupKey,
|
||||||
|
messages,
|
||||||
|
expanded,
|
||||||
|
entryMessageIDs,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
groupKey: string;
|
||||||
|
messages: Message[];
|
||||||
|
expanded: boolean;
|
||||||
|
entryMessageIDs: Set<string>;
|
||||||
|
onToggle: (groupKey: string) => void;
|
||||||
|
}) {
|
||||||
|
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
|
||||||
|
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
|
||||||
|
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
|
||||||
|
const [motionRevision, setMotionRevision] = useState(0);
|
||||||
|
const motionResetTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
setMotionDirection(expanded ? "collapse" : "expand");
|
||||||
|
setMotionRevision((current) => current + 1);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
|
||||||
|
motionResetTimerRef.current = window.setTimeout(() => {
|
||||||
|
setMotionDirection(null);
|
||||||
|
motionResetTimerRef.current = null;
|
||||||
|
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
|
||||||
|
}
|
||||||
|
onToggle(groupKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
|
||||||
|
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
|
||||||
|
)}
|
||||||
|
data-tool-stack-group={groupKey}
|
||||||
|
data-expanded={expanded ? "true" : "false"}
|
||||||
|
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
|
||||||
|
>
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const depth = messages.length - index - 1;
|
||||||
|
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||||
|
const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
|
||||||
|
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
|
||||||
|
isHidden && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
|
||||||
|
aria-hidden={isHidden ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
|
||||||
|
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
||||||
|
</ToolCallStackCardSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!expanded && hiddenCount ? (
|
||||||
|
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||||
|
+{hiddenCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
aria-expanded={expanded ? "true" : "false"}
|
||||||
|
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
|
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||||
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
|
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||||
|
const entryToolCallMessageIDs = useMemo(() => {
|
||||||
|
const seenIDs = seenToolCallMessageIDsRef.current;
|
||||||
|
if (!seenIDs) return new Set<string>();
|
||||||
|
const entryIDs = new Set<string>();
|
||||||
|
for (const id of toolCallMessageIDs) {
|
||||||
|
if (!seenIDs.has(id)) entryIDs.add(id);
|
||||||
|
}
|
||||||
|
return entryIDs;
|
||||||
|
}, [toolCallMessageIDs]);
|
||||||
|
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toolCallMessageIDs.size) return;
|
||||||
|
const seenIDs = seenToolCallMessageIDsRef.current ?? new Set<string>();
|
||||||
|
for (const id of toolCallMessageIDs) seenIDs.add(id);
|
||||||
|
seenToolCallMessageIDsRef.current = seenIDs;
|
||||||
|
}, [toolCallMessageIDs]);
|
||||||
|
|
||||||
|
const toggleToolGroup = (groupKey: string) => {
|
||||||
|
setExpandedToolGroups((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(groupKey)) next.delete(groupKey);
|
||||||
|
else next.add(groupKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
{messages.map((message) => {
|
{renderItems.map((item) => {
|
||||||
|
if (item.kind === "tool_group") {
|
||||||
|
return (
|
||||||
|
<ToolCallStack
|
||||||
|
key={`tool-group-${item.key}`}
|
||||||
|
groupKey={item.key}
|
||||||
|
messages={item.messages}
|
||||||
|
expanded={expandedToolGroups.has(item.key)}
|
||||||
|
entryMessageIDs={entryToolCallMessageIDs}
|
||||||
|
onToggle={toggleToolGroup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = item;
|
||||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||||
if (message.role === "tool" && toolLogMetadata) {
|
if (message.role === "tool" && toolLogMetadata) {
|
||||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
|
||||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
|
||||||
const isFailed = toolLogMetadata.status === "failed";
|
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="flex justify-start">
|
<div key={message.id} className="flex justify-start">
|
||||||
<div
|
<ToolCallCard message={message} className="max-w-[85%]" />
|
||||||
className={cn(
|
|
||||||
"inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
|
||||||
isFailed
|
|
||||||
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
|
|
||||||
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
|
|
||||||
<span>{getToolSummary(message, toolLogMetadata)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||||
|
const attachments = getMessageAttachments(message.metadata);
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[85%]",
|
"max-w-[85%] space-y-3",
|
||||||
isUser
|
isUser
|
||||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||||
: "text-base leading-7 text-violet-50"
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||||
{isPendingAssistant ? (
|
{isPendingAssistant ? (
|
||||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : message.content.trim() ? (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
markdown={message.content}
|
markdown={message.content}
|
||||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked, Renderer } from "marked";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type MarkdownMode = "default" | "citationTokens";
|
type MarkdownMode = "default" | "citationTokens";
|
||||||
@@ -21,8 +21,15 @@ function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitatio
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownRenderer = new Renderer();
|
||||||
|
const renderTable = markdownRenderer.table.bind(markdownRenderer);
|
||||||
|
|
||||||
|
markdownRenderer.table = (token) => {
|
||||||
|
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
function renderMarkdown(markdown: string) {
|
function renderMarkdown(markdown: string) {
|
||||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true }) as string;
|
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
web/src/components/sybil-character.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CHARACTER_IDLE_SRC = "/character-idle.gif";
|
||||||
|
const CHARACTER_BUSY_SRC = "/character-busy.gif";
|
||||||
|
|
||||||
|
type SybilCharacterProps = {
|
||||||
|
className?: string;
|
||||||
|
isBusy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const busyImage = new Image();
|
||||||
|
busyImage.src = CHARACTER_BUSY_SRC;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
aria-hidden="true"
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-state={isBusy ? "busy" : "idle"}
|
||||||
|
draggable={false}
|
||||||
|
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,20 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "StalinistOne";
|
||||||
|
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
: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%;
|
||||||
@@ -32,6 +44,15 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -41,6 +62,8 @@ body {
|
|||||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
linear-gradient(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,
|
||||||
@@ -57,8 +80,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sybil-wordmark {
|
.sybil-wordmark {
|
||||||
font-family: "Orbitron", "Inter", sans-serif;
|
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 400;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -70,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)),
|
||||||
@@ -79,10 +140,223 @@ textarea {
|
|||||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-shell {
|
||||||
|
perspective: 900px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card {
|
||||||
|
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1));
|
||||||
|
transform-origin: top left;
|
||||||
|
opacity: var(--tool-stack-opacity, 1);
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-shell-layout-a {
|
||||||
|
animation: tool-call-stack-height-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-shell-layout-b {
|
||||||
|
animation: tool-call-stack-height-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card-layout-a {
|
||||||
|
animation: tool-call-stack-layout-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card-layout-b {
|
||||||
|
animation: tool-call-stack-layout-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card-surface {
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card-glass {
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-card-enter {
|
||||||
|
animation: tool-call-stack-drop-in 320ms cubic-bezier(0.18, 0.95, 0.28, 1) backwards;
|
||||||
|
animation-delay: var(--tool-stack-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-toggle {
|
||||||
|
border: 1px solid hsl(188 82% 70% / 0.36);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, hsl(230 36% 16% / 0.96), hsl(238 48% 7% / 0.96)),
|
||||||
|
hsl(236 48% 8%);
|
||||||
|
color: hsl(186 92% 86%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 hsl(180 100% 88% / 0.08),
|
||||||
|
0 8px 22px hsl(235 72% 2% / 0.42);
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-toggle:hover {
|
||||||
|
border-color: hsl(188 92% 74% / 0.62);
|
||||||
|
color: hsl(184 100% 92%);
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-toggle:focus-visible {
|
||||||
|
outline: 2px solid hsl(188 92% 72% / 0.9);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-call-stack-height-a {
|
||||||
|
from {
|
||||||
|
height: var(--tool-stack-from-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: var(--tool-stack-to-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-call-stack-height-b {
|
||||||
|
from {
|
||||||
|
height: var(--tool-stack-from-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: var(--tool-stack-to-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-call-stack-layout-a {
|
||||||
|
from {
|
||||||
|
opacity: var(--tool-stack-from-opacity, 1);
|
||||||
|
transform: var(--tool-stack-from-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: var(--tool-stack-to-opacity, 1);
|
||||||
|
transform: var(--tool-stack-to-transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-call-stack-layout-b {
|
||||||
|
from {
|
||||||
|
opacity: var(--tool-stack-from-opacity, 1);
|
||||||
|
transform: var(--tool-stack-from-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: var(--tool-stack-to-opacity, 1);
|
||||||
|
transform: var(--tool-stack-to-transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-call-stack-drop-in {
|
||||||
|
from {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: translate3d(0, -0.65rem, 120px) scale(1.025) rotateX(3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tool-call-stack-card {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-stack-shell-layout-a,
|
||||||
|
.tool-call-stack-shell-layout-b,
|
||||||
|
.tool-call-stack-card-layout-a,
|
||||||
|
.tool-call-stack-card-layout-b,
|
||||||
|
.tool-call-stack-card-enter {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.md-content {
|
.md-content {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-table-scroll {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0.35rem 0 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
border: 1px solid hsl(var(--border) / 0.86);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: hsl(246 34% 10% / 0.76);
|
||||||
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 0.94em;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-table-scroll::-webkit-scrollbar {
|
||||||
|
height: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(263 78% 72% / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content th,
|
||||||
|
.md-content td {
|
||||||
|
padding: 0.48rem 0.7rem;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.72);
|
||||||
|
border-bottom: 1px solid hsl(var(--border) / 0.7);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content th:last-child,
|
||||||
|
.md-content td:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content th {
|
||||||
|
background: hsl(251 40% 15% / 0.92);
|
||||||
|
color: hsl(258 36% 98%);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content td {
|
||||||
|
color: hsl(258 34% 94% / 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content tbody tr:nth-child(odd) td {
|
||||||
|
background: hsl(242 32% 10% / 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content tbody tr:nth-child(even) td {
|
||||||
|
background: hsl(252 36% 13% / 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content tbody tr:hover td {
|
||||||
|
background: hsl(263 46% 20% / 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
.md-content p + p {
|
.md-content p + p {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -113,7 +387,13 @@ textarea {
|
|||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style-position: inside;
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content li > ul,
|
||||||
|
.md-content li > ol {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding-left: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content li + li {
|
.md-content li + li {
|
||||||
@@ -121,17 +401,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;
|
||||||
@@ -90,6 +112,27 @@ export type SearchDetail = {
|
|||||||
results: SearchResultItem[];
|
results: SearchResultItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatImageAttachment = {
|
||||||
|
kind: "image";
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: "image/png" | "image/jpeg";
|
||||||
|
sizeBytes: number;
|
||||||
|
dataUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatTextAttachment = {
|
||||||
|
kind: "text";
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
text: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
|
||||||
|
|
||||||
export type SearchRunRequest = {
|
export type SearchRunRequest = {
|
||||||
query?: string;
|
query?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -103,9 +146,10 @@ export type CompletionRequestMessage = {
|
|||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
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[];
|
||||||
@@ -114,7 +158,17 @@ 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 = {
|
||||||
|
chats: string[];
|
||||||
|
searches: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CompletionResponse = {
|
type CompletionResponse = {
|
||||||
@@ -126,13 +180,34 @@ type CompletionResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CompletionStreamHandlers = {
|
type CompletionStreamHandlers = {
|
||||||
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
|
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
|
||||||
onToolCall?: (payload: ToolCallEvent) => void;
|
onToolCall?: (payload: ToolCallEvent) => void;
|
||||||
onDelta?: (payload: { text: string }) => void;
|
onDelta?: (payload: { text: string }) => void;
|
||||||
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateChatRequest = {
|
||||||
|
title?: string;
|
||||||
|
provider?: Provider;
|
||||||
|
model?: string;
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
messages?: CompletionRequestMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateSearchRequest = {
|
||||||
|
title?: string;
|
||||||
|
query?: string;
|
||||||
|
reuseByQuery?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateSearchResponse = {
|
||||||
|
search: SearchSummary;
|
||||||
|
reused: boolean;
|
||||||
|
cacheHit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
const 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;
|
||||||
@@ -180,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");
|
||||||
}
|
}
|
||||||
@@ -188,10 +268,20 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChat(title?: string) {
|
export async function listChatTools() {
|
||||||
|
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
||||||
|
return data.tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveRuns() {
|
||||||
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChat(input?: string | CreateChatRequest) {
|
||||||
|
const body = typeof input === "string" ? { title: input } : input ?? {};
|
||||||
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ title }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
@@ -209,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",
|
||||||
@@ -226,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",
|
||||||
@@ -251,6 +376,49 @@ export async function deleteSearch(searchId: string) {
|
|||||||
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
|
||||||
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
|
||||||
|
const attachments = (metadata as Record<string, unknown>).attachments;
|
||||||
|
if (!Array.isArray(attachments)) return [];
|
||||||
|
|
||||||
|
const parsed: ChatAttachment[] = [];
|
||||||
|
for (const entry of attachments) {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const kind = record.kind;
|
||||||
|
const id = typeof record.id === "string" ? record.id : "";
|
||||||
|
const filename = typeof record.filename === "string" ? record.filename : "";
|
||||||
|
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
|
||||||
|
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
|
||||||
|
|
||||||
|
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
|
||||||
|
parsed.push({
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes,
|
||||||
|
dataUrl: record.dataUrl,
|
||||||
|
} satisfies ChatImageAttachment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "text" && typeof record.text === "string") {
|
||||||
|
parsed.push({
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes,
|
||||||
|
text: record.text,
|
||||||
|
truncated: record.truncated === true,
|
||||||
|
} satisfies ChatTextAttachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
type RunSearchStreamHandlers = {
|
type RunSearchStreamHandlers = {
|
||||||
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
|
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
|
||||||
onSearchError?: (payload: { error: string }) => void;
|
onSearchError?: (payload: { error: string }) => void;
|
||||||
@@ -260,6 +428,85 @@ type RunSearchStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const fallback = `${response.status} ${response.statusText}`;
|
||||||
|
let message = fallback;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { message?: string };
|
||||||
|
if (body.message) message = body.message;
|
||||||
|
} catch {
|
||||||
|
// keep fallback message
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No response stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let eventName = "message";
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
|
||||||
|
const flushEvent = () => {
|
||||||
|
if (!dataLines.length) {
|
||||||
|
eventName = "message";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataText = dataLines.join("\n");
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(dataText);
|
||||||
|
} catch {
|
||||||
|
payload = { message: dataText };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(eventName, payload);
|
||||||
|
|
||||||
|
dataLines = [];
|
||||||
|
eventName = "message";
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newlineIndex = buffer.indexOf("\n");
|
||||||
|
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const rawLine = buffer.slice(0, newlineIndex);
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
flushEvent();
|
||||||
|
} else if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
|
||||||
|
newlineIndex = buffer.indexOf("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.length) {
|
||||||
|
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushEvent();
|
||||||
|
}
|
||||||
|
|
||||||
export async function runSearchStream(
|
export async function runSearchStream(
|
||||||
searchId: string,
|
searchId: string,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -364,11 +611,38 @@ export async function runSearchStream(
|
|||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await readSseStream(response, (eventName, payload) => {
|
||||||
|
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||||
|
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||||
|
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||||
|
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCompletion(body: {
|
export async function runCompletion(body: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
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",
|
||||||
@@ -378,10 +652,14 @@ export async function runCompletion(body: {
|
|||||||
|
|
||||||
export async function runCompletionStream(
|
export async function runCompletionStream(
|
||||||
body: {
|
body: {
|
||||||
chatId: string;
|
chatId?: string | null;
|
||||||
|
persist?: boolean;
|
||||||
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 }
|
||||||
@@ -482,3 +760,26 @@ export async function runCompletionStream(
|
|||||||
}
|
}
|
||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await readSseStream(response, (eventName, payload) => {
|
||||||
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { render } from "preact";
|
import { 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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||