Compare commits
13 Commits
a6c2ec664b
...
v1.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 7436544a69 | |||
| 95796646b1 | |||
| d7214c88ad | |||
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
| 600bc3befc | |||
| 5b7ed25522 | |||
| 39014eee18 |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -17,6 +17,11 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
default_type application/manifest+json;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ Chat upload limits:
|
|||||||
- `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.
|
- `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.
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|
||||||
|
## Chat Tools
|
||||||
|
|
||||||
|
### `GET /v1/chat-tools`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{ "name": "web_search", "description": "..." },
|
||||||
|
{ "name": "fetch_url", "description": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions.
|
||||||
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
|
|
||||||
### `GET /v1/active-runs`
|
### `GET /v1/active-runs`
|
||||||
@@ -77,7 +94,9 @@ Behavior notes:
|
|||||||
"initiatedProvider": "openai",
|
"initiatedProvider": "openai",
|
||||||
"initiatedModel": "gpt-4.1-mini",
|
"initiatedModel": "gpt-4.1-mini",
|
||||||
"lastUsedProvider": "openai",
|
"lastUsedProvider": "openai",
|
||||||
"lastUsedModel": "gpt-4.1-mini"
|
"lastUsedModel": "gpt-4.1-mini",
|
||||||
|
"additionalSystemPrompt": null,
|
||||||
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "search",
|
"type": "search",
|
||||||
@@ -111,6 +130,8 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
|
"additionalSystemPrompt": "optional stored system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system|user|assistant|tool",
|
"role": "system|user|assistant|tool",
|
||||||
@@ -126,13 +147,17 @@ Behavior notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `provider` and `model` must be supplied together when present.
|
- `provider` and `model` must be supplied together when present.
|
||||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||||
|
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||||
|
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: `{ "title": string }`
|
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||||
- Renaming updates the returned chat's `updatedAt`.
|
- `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" }`
|
- Not found: `404 { "message": "chat not found" }`
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId/star`
|
### `PATCH /v1/chats/:chatId/star`
|
||||||
@@ -237,6 +262,8 @@ Notes:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -256,6 +283,8 @@ Notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If `chatId` is present, server validates chat existence.
|
- If `chatId` is present, server validates chat existence.
|
||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
@@ -270,7 +299,7 @@ Behavior notes:
|
|||||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||||
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
- `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:
|
- Devbox tool configuration:
|
||||||
@@ -285,7 +314,7 @@ Behavior notes:
|
|||||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||||
- `anthropic` currently runs without server-managed tool calls.
|
- `anthropic` currently runs without server-managed tool calls.
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
@@ -294,8 +323,14 @@ 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 }`
|
- 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`
|
### `PATCH /v1/searches/:searchId/star`
|
||||||
- Body: `{ "starred": boolean }`
|
- Body: `{ "starred": boolean }`
|
||||||
@@ -384,7 +419,9 @@ Behavior notes:
|
|||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null"
|
"lastUsedModel": "string|null",
|
||||||
|
"additionalSystemPrompt": null,
|
||||||
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -435,6 +472,8 @@ Behavior notes:
|
|||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|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]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Authentication:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,8 @@ Notes:
|
|||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
@@ -87,6 +91,8 @@ Event order:
|
|||||||
3. Zero or more `delta`
|
3. Zero or more `delta`
|
||||||
4. Exactly one terminal event: `done` or `error`
|
4. Exactly one terminal event: `done` or `error`
|
||||||
|
|
||||||
|
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
|
||||||
|
|
||||||
### `meta`
|
### `meta`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -111,6 +117,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
|
|
||||||
### `tool_call`
|
### `tool_call`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toolCallId": "call_123",
|
||||||
|
"name": "web_search",
|
||||||
|
"status": "initiated",
|
||||||
|
"summary": "Searching web for 'latest CPI release'.",
|
||||||
|
"args": { "query": "latest CPI release" },
|
||||||
|
"startedAt": "2026-03-02T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal tool-call event:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"toolCallId": "call_123",
|
"toolCallId": "call_123",
|
||||||
@@ -121,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
"startedAt": "2026-03-02T10:00:00.000Z",
|
"startedAt": "2026-03-02T10:00:00.000Z",
|
||||||
"completedAt": "2026-03-02T10:00:00.820Z",
|
"completedAt": "2026-03-02T10:00:00.820Z",
|
||||||
"durationMs": 820,
|
"durationMs": 820,
|
||||||
"error": null,
|
|
||||||
"resultPreview": "{\"ok\":true,...}"
|
"resultPreview": "{\"ok\":true,...}"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
|
||||||
|
|
||||||
### `done`
|
### `done`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -152,6 +172,7 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
|
|
||||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||||
|
- `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.
|
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||||
@@ -174,7 +195,8 @@ Backend database remains source of truth.
|
|||||||
|
|
||||||
For persisted streams:
|
For persisted streams:
|
||||||
- Client may optimistically render accumulated `delta` text.
|
- Client may optimistically render accumulated `delta` text.
|
||||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
- Backend emits initiated tool-call events without persisting them.
|
||||||
|
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||||
|
|
||||||
On successful persisted completion:
|
On successful persisted completion:
|
||||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||||
|
|||||||
20
ios/.env.example
Normal file
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
|
FASTLANE_USER=you@example.com
|
||||||
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
|
||||||
|
# Optional App Store Connect API key settings for non-interactive upload and
|
||||||
|
# TestFlight build-number lookup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
11
ios/.gitignore
vendored
11
ios/.gitignore
vendored
@@ -1,2 +1,11 @@
|
|||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.9
|
MARKETING_VERSION: "1.10"
|
||||||
CURRENT_PROJECT_VERSION: 10
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
3
ios/Gemfile
Normal file
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane", "~> 2.227"
|
||||||
@@ -661,6 +661,7 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -7,39 +7,107 @@ struct SybilChatTranscriptView: View {
|
|||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
messages.contains { message in
|
private var renderItems: [TranscriptRenderItem] {
|
||||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
buildTranscriptRenderItems(from: messages)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
ForEach(messages.reversed()) { message in
|
|
||||||
MessageBubble(message: message, isSending: isSending)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
if isLoading && messages.isEmpty {
|
||||||
Text("Loading messages…")
|
Text("Loading messages…")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ForEach(renderItems) { item in
|
||||||
|
switch item {
|
||||||
|
case let .message(message):
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
case let .toolGroup(id, messages):
|
||||||
|
ToolCallStackView(groupID: id, messages: messages)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 18 + bottomContentInset)
|
||||||
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
.padding(.bottom, 18 + topContentInset)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.onAppear {
|
||||||
|
scrollToBottom(with: proxy, animated: false)
|
||||||
}
|
}
|
||||||
|
.onChange(of: bottomPinRequestID) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
let action = {
|
||||||
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.18), action)
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -137,10 +205,207 @@ 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]
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true)
|
||||||
|
.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
|
||||||
|
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var didEnter = false
|
||||||
|
|
||||||
|
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(didEnter ? 1 : 1.025, anchor: .topLeading)
|
||||||
|
.offset(y: didEnter ? 0 : -8)
|
||||||
|
.rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||||
|
.opacity(didEnter ? 1 : 0.72)
|
||||||
|
.onAppear {
|
||||||
|
guard !didEnter else { return }
|
||||||
|
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 {
|
||||||
@@ -184,11 +449,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")
|
||||||
}
|
}
|
||||||
@@ -200,14 +476,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)
|
||||||
|
|
||||||
@@ -216,12 +492,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)
|
||||||
@@ -236,12 +514,45 @@ private struct ToolCallActivityChip: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
.fill(backgroundGradient)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 520, alignment: .leading)
|
.frame(maxWidth: 520, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var stateLabel: String {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return "Failed"
|
||||||
|
case .initiated:
|
||||||
|
return "Running"
|
||||||
|
case .completed:
|
||||||
|
return "Completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return SybilTheme.danger
|
||||||
|
case .initiated:
|
||||||
|
return SybilTheme.warning
|
||||||
|
case .completed:
|
||||||
|
return SybilTheme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundGradient: LinearGradient {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return SybilTheme.failedToolCallGradient
|
||||||
|
case .initiated:
|
||||||
|
return SybilTheme.runningToolCallGradient
|
||||||
|
case .completed:
|
||||||
|
return SybilTheme.toolCallGradient
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ enum SybilTheme {
|
|||||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||||
|
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
|
||||||
|
|
||||||
@MainActor static func applySystemAppearance() {
|
@MainActor static func applySystemAppearance() {
|
||||||
let navAppearance = UINavigationBarAppearance()
|
let navAppearance = UINavigationBarAppearance()
|
||||||
@@ -186,6 +187,17 @@ enum SybilTheme {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var runningToolCallGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
|
||||||
|
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static var failedToolCallGradient: LinearGradient {
|
static var failedToolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
@@ -1186,7 +1187,7 @@ final class SybilViewModel {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case let .toolCall(payload):
|
case let .toolCall(payload):
|
||||||
insertQuickQuestionToolCallMessage(payload)
|
upsertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
@@ -1699,6 +1700,10 @@ final class SybilViewModel {
|
|||||||
isLoadingSelection = false
|
isLoadingSelection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestChatBottomPin() {
|
||||||
|
chatBottomPinRequestID += 1
|
||||||
|
}
|
||||||
|
|
||||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
@@ -1752,6 +1757,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
@@ -1824,6 +1830,7 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
@@ -1871,6 +1878,7 @@ final class SybilViewModel {
|
|||||||
if let draftPending = pendingDraftChatState {
|
if let draftPending = pendingDraftChatState {
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
||||||
|
requestChatBottomPin()
|
||||||
} else if pendingChatStates[chatID] == nil {
|
} else if pendingChatStates[chatID] == nil {
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
||||||
} else {
|
} else {
|
||||||
@@ -2006,7 +2014,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case let .toolCall(payload):
|
case let .toolCall(payload):
|
||||||
insertPendingToolCallMessage(payload, chatID: chatID)
|
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
@@ -2222,12 +2230,14 @@ final class SybilViewModel {
|
|||||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
guard var pending = pendingChatStates[chatID] else {
|
guard var pending = pendingChatStates[chatID] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||||
|
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||||
|
pendingChatStates[chatID] = pending
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2242,8 +2252,9 @@ final class SybilViewModel {
|
|||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||||
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||||
|
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2255,8 +2266,8 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
|
||||||
let metadata: JSONValue = .object([
|
var metadataObject: [String: JSONValue] = [
|
||||||
"kind": .string("tool_call"),
|
"kind": .string("tool_call"),
|
||||||
"toolCallId": .string(payload.toolCallId),
|
"toolCallId": .string(payload.toolCallId),
|
||||||
"toolName": .string(payload.name),
|
"toolName": .string(payload.name),
|
||||||
@@ -2264,19 +2275,26 @@ final class SybilViewModel {
|
|||||||
"summary": .string(payload.summary),
|
"summary": .string(payload.summary),
|
||||||
"args": .object(payload.args),
|
"args": .object(payload.args),
|
||||||
"startedAt": .string(payload.startedAt),
|
"startedAt": .string(payload.startedAt),
|
||||||
"completedAt": .string(payload.completedAt),
|
|
||||||
"durationMs": .number(Double(payload.durationMs)),
|
|
||||||
"error": payload.error.map { .string($0) } ?? .null,
|
"error": payload.error.map { .string($0) } ?? .null,
|
||||||
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if let completedAt = payload.completedAt {
|
||||||
|
metadataObject["completedAt"] = .string(completedAt)
|
||||||
|
}
|
||||||
|
if let durationMs = payload.durationMs {
|
||||||
|
metadataObject["durationMs"] = .number(Double(durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata: JSONValue = .object(metadataObject)
|
||||||
|
|
||||||
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
? "Ran tool '\(payload.name)'."
|
? "Ran tool '\(payload.name)'."
|
||||||
: payload.summary
|
: payload.summary
|
||||||
|
|
||||||
return Message(
|
return Message(
|
||||||
id: "temp-tool-\(payload.toolCallId)",
|
id: id ?? "temp-tool-\(payload.toolCallId)",
|
||||||
createdAt: Date(),
|
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
|
||||||
role: .tool,
|
role: .tool,
|
||||||
content: summary,
|
content: summary,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
@@ -2284,6 +2302,19 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toolCallDate(from value: String?) -> Date? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
let fractionalFormatter = ISO8601DateFormatter()
|
||||||
|
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = fractionalFormatter.date(from: value) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
private var currentChatID: String? {
|
private var currentChatID: String? {
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
|
|||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
|
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,70 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message {
|
||||||
|
Message(
|
||||||
|
id: id,
|
||||||
|
createdAt: date,
|
||||||
|
role: .tool,
|
||||||
|
content: summary,
|
||||||
|
name: "web_search",
|
||||||
|
metadata: .object([
|
||||||
|
"kind": .string("tool_call"),
|
||||||
|
"toolCallId": .string("call-\(id)"),
|
||||||
|
"toolName": .string("web_search"),
|
||||||
|
"status": .string("completed"),
|
||||||
|
"summary": .string(summary),
|
||||||
|
"durationMs": .number(120)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||||
|
let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A")
|
||||||
|
let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B")
|
||||||
|
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||||
|
|
||||||
|
let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant])
|
||||||
|
|
||||||
|
#expect(items.count == 3)
|
||||||
|
guard case let .message(firstMessage) = items[0] else {
|
||||||
|
Issue.record("Expected the first item to remain a normal message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(firstMessage.id == "user-1")
|
||||||
|
|
||||||
|
guard case let .toolGroup(groupID, groupedMessages) = items[1] else {
|
||||||
|
Issue.record("Expected adjacent tool calls to be grouped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(groupID == "tool-a")
|
||||||
|
#expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"])
|
||||||
|
|
||||||
|
guard case let .message(lastMessage) = items[2] else {
|
||||||
|
Issue.record("Expected the assistant response to remain a normal message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(lastMessage.id == "assistant-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||||
|
let tool = makeToolCallMessage(id: "tool-a", date: date)
|
||||||
|
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||||
|
|
||||||
|
let items = buildTranscriptRenderItems(from: [user, tool, assistant])
|
||||||
|
|
||||||
|
#expect(items.count == 3)
|
||||||
|
guard case let .message(toolMessage) = items[1] else {
|
||||||
|
Issue.record("Expected a single tool call to use the existing inline chip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(toolMessage.id == "tool-a")
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||||
let defaults = UserDefaults(suiteName: #function)!
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
@@ -495,6 +559,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -682,6 +747,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
await sendTask.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_245)
|
||||||
|
let chat = makeChatSummary(id: "chat-pin", date: date)
|
||||||
|
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-pin": detail]
|
||||||
|
)
|
||||||
|
await client.setCompletionStreamEvents([
|
||||||
|
.delta(CompletionStreamDelta(text: "partial ")),
|
||||||
|
.delta(CompletionStreamDelta(text: "response")),
|
||||||
|
.done(CompletionStreamDone(text: "partial response"))
|
||||||
|
])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [chat]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
|
||||||
|
viewModel.selectedItem = .chat("chat-pin")
|
||||||
|
viewModel.selectedChat = detail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let initialPinRequestID = viewModel.chatBottomPinRequestID
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.runCompletionStream == 1)
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
|
|||||||
9
ios/fastlane/Appfile
Normal file
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
require "dotenv"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
177
ios/fastlane/Fastfile
Normal file
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(command)
|
||||||
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_project_settings
|
||||||
|
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_build_number
|
||||||
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = nil
|
||||||
|
|
||||||
|
if app_store_connect_key_options
|
||||||
|
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
if api_key
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store-connect",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "automatic",
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||||
|
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||||
|
|
||||||
|
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||||
|
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||||
|
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||||
|
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||||
|
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||||
|
|
||||||
|
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||||
|
sh([
|
||||||
|
"xcrun altool",
|
||||||
|
"--upload-package #{ipa_path.shellescape}",
|
||||||
|
"--platform ios",
|
||||||
|
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||||
|
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||||
|
"--bundle-version #{build_number.shellescape}",
|
||||||
|
"--bundle-short-version-string #{version.shellescape}",
|
||||||
|
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||||
|
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||||
|
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||||
|
"--show-progress"
|
||||||
|
].join(" "))
|
||||||
|
end
|
||||||
|
end
|
||||||
40
ios/fastlane/README.md
Normal file
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
12
ios/justfile
12
ios/justfile
@@ -5,8 +5,10 @@ derived_data := "build/DerivedData"
|
|||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
generate:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
xcodegen --spec project.yml
|
||||||
|
|
||||||
|
build: generate
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
@@ -16,13 +18,15 @@ build:
|
|||||||
test:
|
test:
|
||||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||||
|
|
||||||
run:
|
run: generate
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
|
||||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
beta:
|
||||||
|
fastlane ios beta
|
||||||
|
|
||||||
screenshot path="build/sybil-screenshot.png":
|
screenshot path="build/sybil-screenshot.png":
|
||||||
mkdir -p "$(dirname '{{path}}')"
|
mkdir -p "$(dirname '{{path}}')"
|
||||||
xcrun simctl io booted screenshot '{{path}}'
|
xcrun simctl io booted screenshot '{{path}}'
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||||
@@ -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");
|
||||||
@@ -57,6 +57,9 @@ 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?
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ model Search {
|
|||||||
|
|
||||||
title String?
|
title String?
|
||||||
query String?
|
query String?
|
||||||
|
queryNormalized String?
|
||||||
|
|
||||||
source SearchSource @default(exa)
|
source SearchSource @default(exa)
|
||||||
|
|
||||||
@@ -139,6 +143,7 @@ model Search {
|
|||||||
projectItems ProjectItem[]
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
|
@@index([queryNormalized, updatedAt])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
server/src/browser-fetch-headers.ts
Normal file
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ 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 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 { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
import {
|
||||||
|
buildOpenAIConversationMessage,
|
||||||
|
buildOpenAIResponsesInputMessage,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
} from "./message-content.js";
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||||
@@ -188,7 +193,43 @@ const CHAT_TOOLS: any[] = [
|
|||||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
function getToolName(tool: any) {
|
||||||
|
return typeof tool?.function?.name === "string" ? tool.function.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableChatTools() {
|
||||||
|
return CHAT_TOOLS.map((tool) => {
|
||||||
|
const name = getToolName(tool);
|
||||||
|
if (!name) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
|
||||||
|
};
|
||||||
|
}).filter((tool): tool is { name: string; description: string } => tool !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEnabledChatTools(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
|
||||||
|
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
|
||||||
|
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||||
|
available.has(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
const enabled = getEnabledToolSet(params);
|
||||||
|
return CHAT_TOOLS.filter((tool) => {
|
||||||
|
const name = getToolName(tool);
|
||||||
|
return name ? enabled.has(name) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResponsesChatTools(tools: any[]) {
|
||||||
|
return tools.map((tool) => {
|
||||||
if (tool?.type !== "function") return tool;
|
if (tool?.type !== "function") return tool;
|
||||||
return {
|
return {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -198,6 +239,7 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
|||||||
strict: false,
|
strict: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
@@ -239,6 +281,8 @@ type ToolAwareCompletionParams = {
|
|||||||
client: OpenAI;
|
client: OpenAI;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||||
@@ -249,15 +293,17 @@ type ToolAwareCompletionParams = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
|
||||||
|
|
||||||
export type ToolExecutionEvent = {
|
export type ToolExecutionEvent = {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "completed" | "failed";
|
status: ToolExecutionStatus;
|
||||||
summary: string;
|
summary: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string;
|
completedAt?: string;
|
||||||
durationMs: number;
|
durationMs?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
};
|
};
|
||||||
@@ -285,10 +331,13 @@ function toSingleLine(value: string, maxLength = 220) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
|
||||||
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
const query = typeof args.query === "string" ? args.query.trim() : "";
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
||||||
}
|
}
|
||||||
@@ -297,6 +346,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "fetch_url") {
|
if (name === "fetch_url") {
|
||||||
const url = typeof args.url === "string" ? args.url.trim() : "";
|
const url = typeof args.url === "string" ? args.url.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
||||||
}
|
}
|
||||||
@@ -305,6 +357,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "codex_exec") {
|
if (name === "codex_exec") {
|
||||||
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
||||||
}
|
}
|
||||||
@@ -313,6 +368,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "shell_exec") {
|
if (name === "shell_exec") {
|
||||||
const command = typeof args.command === "string" ? args.command.trim() : "";
|
const command = typeof args.command === "string" ? args.command.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
||||||
}
|
}
|
||||||
@@ -321,6 +379,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
: `Devbox shell command failed.${errSuffix}`;
|
: `Devbox shell command failed.${errSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "initiated") {
|
||||||
|
return `Running tool '${name}'.`;
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return `Ran tool '${name}'.`;
|
return `Ran tool '${name}'.`;
|
||||||
}
|
}
|
||||||
@@ -379,20 +440,38 @@ function extractHtmlTitle(html: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
const enabled = getEnabledToolSet(params);
|
||||||
|
return (
|
||||||
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
|
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
|
||||||
|
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
|
||||||
|
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
|
||||||
|
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
|
||||||
|
(enabled.has("codex_exec")
|
||||||
|
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
|
||||||
|
: "") +
|
||||||
|
(enabled.has("shell_exec")
|
||||||
|
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
|
||||||
|
: "") +
|
||||||
|
"Do not fabricate tool outputs; reason only from provided tool results."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||||
|
|
||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||||
return messages.map((message) => buildOpenAIConversationMessage(message));
|
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||||
|
|
||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
@@ -492,10 +571,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);
|
||||||
@@ -908,17 +984,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeToolCallAndBuildEvent(
|
type PreparedToolCallExecution = {
|
||||||
call: NormalizedToolCall,
|
startedAtMs: number;
|
||||||
params: ToolAwareCompletionParams
|
startedAt: string;
|
||||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
parsedArgs: Record<string, unknown>;
|
||||||
|
eventArgs: Record<string, unknown>;
|
||||||
|
parseError?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const startedAt = new Date(startedAtMs).toISOString();
|
const startedAt = new Date(startedAtMs).toISOString();
|
||||||
let toolResult: ToolRunOutcome;
|
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
let parseError: unknown;
|
||||||
try {
|
try {
|
||||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||||
toolResult = await executeTool(call.name, parsedArgs);
|
} catch (err) {
|
||||||
|
parseError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
toolCallId: call.id,
|
||||||
|
name: call.name,
|
||||||
|
status: "initiated",
|
||||||
|
summary: buildToolSummary(call.name, eventArgs, "initiated"),
|
||||||
|
args: eventArgs,
|
||||||
|
startedAt,
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
startedAtMs,
|
||||||
|
startedAt,
|
||||||
|
parsedArgs,
|
||||||
|
eventArgs,
|
||||||
|
parseError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolCallAndBuildEvent(
|
||||||
|
call: NormalizedToolCall,
|
||||||
|
execution: PreparedToolCallExecution,
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||||
|
let toolResult: ToolRunOutcome;
|
||||||
|
try {
|
||||||
|
if (execution.parseError) throw execution.parseError;
|
||||||
|
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toolResult = {
|
toolResult = {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -935,16 +1049,15 @@ async function executeToolCallAndBuildEvent(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const completedAtMs = Date.now();
|
const completedAtMs = Date.now();
|
||||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
|
||||||
const event: ToolExecutionEvent = {
|
const event: ToolExecutionEvent = {
|
||||||
toolCallId: call.id,
|
toolCallId: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
status,
|
status,
|
||||||
summary: buildToolSummary(call.name, eventArgs, status, error),
|
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||||
args: eventArgs,
|
args: execution.eventArgs,
|
||||||
startedAt,
|
startedAt: execution.startedAt,
|
||||||
completedAt: new Date(completedAtMs).toISOString(),
|
completedAt: new Date(completedAtMs).toISOString(),
|
||||||
durationMs: completedAtMs - startedAtMs,
|
durationMs: completedAtMs - execution.startedAtMs,
|
||||||
error,
|
error,
|
||||||
resultPreview: buildResultPreview(toolResult),
|
resultPreview: buildResultPreview(toolResult),
|
||||||
};
|
};
|
||||||
@@ -957,7 +1070,8 @@ async function executeToolCallAndBuildEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -971,7 +1085,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: RESPONSES_CHAT_TOOLS,
|
tools: toResponsesChatTools(enabledTools),
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1006,7 +1120,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
input.push(...outputItems);
|
input.push(...outputItems);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
|
|
||||||
input.push({
|
input.push({
|
||||||
@@ -1026,7 +1141,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1040,7 +1156,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: CHAT_TOOLS,
|
tools: enabledTools,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
} as any);
|
} as any);
|
||||||
rawResponses.push(completion);
|
rawResponses.push(completion);
|
||||||
@@ -1092,7 +1208,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
conversation.push(assistantToolCallMessage);
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
|
|
||||||
conversation.push({
|
conversation.push({
|
||||||
@@ -1114,7 +1231,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const completion = await params.client.chat.completions.create({
|
const completion = await params.client.chat.completions.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
messages: normalizePlainIncomingMessages(params.messages),
|
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -1134,7 +1251,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
|
|||||||
export async function* runToolAwareOpenAIChatStream(
|
export async function* runToolAwareOpenAIChatStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1148,7 +1266,7 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: RESPONSES_CHAT_TOOLS,
|
tools: toResponsesChatTools(enabledTools),
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1235,7 +1353,9 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
input.push(...responseOutputItems);
|
input.push(...responseOutputItems);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
yield { type: "tool_call", event };
|
yield { type: "tool_call", event };
|
||||||
input.push({
|
input.push({
|
||||||
@@ -1260,7 +1380,8 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
export async function* runToolAwareChatCompletionsStream(
|
export async function* runToolAwareChatCompletionsStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1274,7 +1395,7 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: CHAT_TOOLS,
|
tools: enabledTools,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
@@ -1371,7 +1492,9 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
conversation.push(assistantToolCallMessage);
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
yield { type: "tool_call", event };
|
yield { type: "tool_call", event };
|
||||||
conversation.push({
|
conversation.push({
|
||||||
@@ -1403,7 +1526,7 @@ export async function* runPlainChatCompletionsStream(
|
|||||||
|
|
||||||
const stream = await params.client.chat.completions.create({
|
const stream = await params.client.chat.completions.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
messages: normalizePlainIncomingMessages(params.messages),
|
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_USER_LOCATION = "San Francisco, CA";
|
||||||
|
|
||||||
|
function currentDateString(now = new Date()) {
|
||||||
|
return now.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserLocation(userLocation?: string) {
|
||||||
|
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
|
||||||
|
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeAttribute(value: string) {
|
function escapeAttribute(value: string) {
|
||||||
return value.replace(/"/g, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content: buildSystemPromptAugmentation(userLocation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
||||||
|
|
||||||
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
|
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
import { toPrismaProvider } from "./provider-ids.js";
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
|
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||||
|
|
||||||
if (req.provider === "openai") {
|
if (req.provider === "openai" && enabledTools.length > 0) {
|
||||||
const client = openaiClient();
|
const client = openaiClient();
|
||||||
const r = await runToolAwareOpenAIChat({
|
const r = await runToolAwareOpenAIChat({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "xai") {
|
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
||||||
const client = xaiClient();
|
const client = xaiClient();
|
||||||
const r = await runToolAwareChatCompletions({
|
const r = await runToolAwareChatCompletions({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -84,12 +89,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "hermes-agent") {
|
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
const client = hermesAgentClient();
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
const r = await runPlainChatCompletions({
|
const r = await runPlainChatCompletions({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -104,7 +110,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages);
|
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const r = await client.messages.create({
|
const r = await client.messages.create({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
|
|||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import {
|
import {
|
||||||
buildToolLogMessageData,
|
buildToolLogMessageData,
|
||||||
|
normalizeEnabledChatTools,
|
||||||
runPlainChatCompletionsStream,
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
@@ -76,12 +77,15 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
|
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||||
const streamEvents =
|
const streamEvents =
|
||||||
req.provider === "openai"
|
req.provider === "openai" && enabledTools.length > 0
|
||||||
? runToolAwareOpenAIChatStream({
|
? runToolAwareOpenAIChatStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -90,11 +94,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId: chatId ?? undefined,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: req.provider === "hermes-agent"
|
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||||
? runPlainChatCompletionsStream({
|
? runPlainChatCompletionsStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -107,6 +112,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -123,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "tool_call") {
|
if (ev.type === "tool_call") {
|
||||||
if (shouldPersist && chatId) {
|
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||||
await prisma.message.create({
|
await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -146,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages);
|
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const stream = await client.messages.create({
|
const stream = await client.messages.create({
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export type MultiplexRequest = {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import { env } from "./env.js";
|
|||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
|
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
@@ -47,6 +51,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
|||||||
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHeaderString(req: FastifyRequest, name: string) {
|
||||||
|
const value = req.headers[name.toLowerCase()];
|
||||||
|
if (Array.isArray(value)) return value.find((item) => item.trim());
|
||||||
|
return typeof value === "string" && value.trim() ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHeaderPart(value: string | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRequestUserLocation(req: FastifyRequest) {
|
||||||
|
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
|
||||||
|
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
|
||||||
|
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
|
||||||
|
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
|
||||||
|
if (vercelLocation) return vercelLocation;
|
||||||
|
|
||||||
|
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
|
||||||
|
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
|
||||||
|
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
|
||||||
|
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
|
||||||
|
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||||
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
@@ -131,6 +172,9 @@ const CompletionStreamBody = z
|
|||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
@@ -155,6 +199,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
|
||||||
|
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
|
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
additionalSystemPrompt,
|
||||||
|
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
|
||||||
|
body: T
|
||||||
|
) {
|
||||||
|
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
|
||||||
|
return prependAdditionalSystemPrompt(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await prisma.chat.findUnique({
|
||||||
|
where: { id: body.chatId },
|
||||||
|
select: { additionalSystemPrompt: true, enabledTools: true },
|
||||||
|
});
|
||||||
|
if (!chat) return prependAdditionalSystemPrompt(body);
|
||||||
|
|
||||||
|
return prependAdditionalSystemPrompt({
|
||||||
|
...body,
|
||||||
|
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
|
||||||
|
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SearchRunBody = z.object({
|
const SearchRunBody = z.object({
|
||||||
query: z.string().trim().min(1).optional(),
|
query: z.string().trim().min(1).optional(),
|
||||||
title: z.string().trim().min(1).optional(),
|
title: z.string().trim().min(1).optional(),
|
||||||
@@ -338,6 +417,8 @@ const chatSummarySelect = {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
projectItems: starredProjectItemsSelect,
|
projectItems: starredProjectItemsSelect,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -375,7 +456,7 @@ function serializeChatLike<T extends Record<string, any>>(chat: T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
||||||
const { projectItems: _projectItems, ...rest } = search;
|
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
...serializeStarFields(search),
|
...serializeStarFields(search),
|
||||||
@@ -649,6 +730,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
|
queryNormalized: normalizeSearchQuery(query),
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
requestId: searchResponse?.requestId ?? null,
|
requestId: searchResponse?.requestId ?? null,
|
||||||
rawResponse: searchResponse as any,
|
rawResponse: searchResponse as any,
|
||||||
@@ -686,6 +768,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
|
queryNormalized: normalizeSearchQuery(query),
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
latencyMs: Math.round(performance.now() - startedAt),
|
latencyMs: Math.round(performance.now() - startedAt),
|
||||||
error: message,
|
error: message,
|
||||||
@@ -713,6 +796,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { providers: getModelCatalogSnapshot() };
|
return { providers: getModelCatalogSnapshot() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/v1/chat-tools", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
return { tools: getAvailableChatTools() };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/v1/active-runs", async (req) => {
|
app.get("/v1/active-runs", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
return {
|
return {
|
||||||
@@ -743,6 +831,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
provider: ProviderSchema.optional(),
|
provider: ProviderSchema.optional(),
|
||||||
model: z.string().trim().min(1).optional(),
|
model: z.string().trim().min(1).optional(),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
messages: z.array(CompletionMessageSchema).optional(),
|
messages: z.array(CompletionMessageSchema).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
@@ -771,6 +861,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: body.model,
|
initiatedModel: body.model,
|
||||||
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
lastUsedModel: body.model,
|
lastUsedModel: body.model,
|
||||||
|
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
||||||
|
enabledTools: body.enabledTools as any,
|
||||||
messages: body.messages?.length
|
messages: body.messages?.length
|
||||||
? {
|
? {
|
||||||
create: body.messages.map((message) => ({
|
create: body.messages.map((message) => ({
|
||||||
@@ -790,13 +882,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
const Body = z.object({ title: z.string().trim().min(1) });
|
const Body = z.object({
|
||||||
|
title: z.string().trim().min(1).optional(),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
});
|
||||||
const { chatId } = Params.parse(req.params);
|
const { chatId } = Params.parse(req.params);
|
||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.title !== undefined) data.title = body.title;
|
||||||
|
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
|
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||||
|
|
||||||
const updated = await prisma.chat.updateMany({
|
const updated = await prisma.chat.updateMany({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: { title: body.title },
|
data: data as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||||
@@ -877,18 +978,51 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/v1/searches", async (req) => {
|
app.post("/v1/searches", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
|
const Body = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
reuseByQuery: z.boolean().optional(),
|
||||||
|
});
|
||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
||||||
const query = body.query?.trim() || null;
|
const query = body.query?.trim() || null;
|
||||||
|
const queryNormalized = normalizeSearchQuery(query);
|
||||||
|
|
||||||
|
if (body.reuseByQuery && queryNormalized) {
|
||||||
|
const existing = await prisma.search.findFirst({
|
||||||
|
where: { queryNormalized },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
...searchSummarySelect,
|
||||||
|
answerText: true,
|
||||||
|
_count: { select: { results: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const { _count, answerText: _answerText, ...search } = existing;
|
||||||
|
return {
|
||||||
|
search: serializeSearchLike(search),
|
||||||
|
reused: true,
|
||||||
|
cacheHit: isFreshSearchCacheHit({
|
||||||
|
updatedAt: existing.updatedAt,
|
||||||
|
resultCount: _count.results,
|
||||||
|
answerText: existing.answerText,
|
||||||
|
isActive: activeSearchStreams.has(existing.id),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const search = await prisma.search.create({
|
const search = await prisma.search.create({
|
||||||
data: {
|
data: {
|
||||||
title: title || null,
|
title: title || null,
|
||||||
query,
|
query,
|
||||||
|
queryNormalized,
|
||||||
},
|
},
|
||||||
select: searchSummarySelect,
|
select: searchSummarySelect,
|
||||||
});
|
});
|
||||||
return { search: serializeSearchLike(search) };
|
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/searches/:searchId/star", async (req) => {
|
app.patch("/v1/searches/:searchId/star", async (req) => {
|
||||||
@@ -1032,6 +1166,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
|
queryNormalized: normalizeSearchQuery(query),
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
requestId: searchResponse?.requestId ?? null,
|
requestId: searchResponse?.requestId ?? null,
|
||||||
rawResponse: searchResponse as any,
|
rawResponse: searchResponse as any,
|
||||||
@@ -1174,13 +1309,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1193,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runMultiplex(body);
|
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId: body.chatId ?? null,
|
chatId: body.chatId ?? null,
|
||||||
@@ -1207,7 +1345,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1224,14 +1362,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (activeChatStreams.has(body.chatId)) {
|
if (activeChatStreams.has(body.chatId)) {
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
const stream = startActiveChatStream(body.chatId, body);
|
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||||
return streamActiveRun(req, reply, stream);
|
return streamActiveRun(req, reply, stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
server/src/search-cache.ts
Normal file
29
server/src/search-cache.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||||
|
const normalized = value?.trim().toLowerCase() ?? "";
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||||
|
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFreshSearchCacheHit(
|
||||||
|
candidate: {
|
||||||
|
updatedAt: Date | string;
|
||||||
|
resultCount: number;
|
||||||
|
answerText?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
now = new Date(),
|
||||||
|
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
if (candidate.isActive) return false;
|
||||||
|
if (!hasReusableSearchPayload(candidate)) return false;
|
||||||
|
|
||||||
|
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||||
|
if (!Number.isFinite(updatedAtMs)) return false;
|
||||||
|
|
||||||
|
return now.getTime() - updatedAtMs <= ttlMs;
|
||||||
|
}
|
||||||
@@ -1,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);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import {
|
||||||
runPlainChatCompletionsStream,
|
runPlainChatCompletionsStream,
|
||||||
|
runToolAwareChatCompletions,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
@@ -140,3 +141,142 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
);
|
);
|
||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
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 runToolAwareChatCompletions({
|
||||||
|
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("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async () => {
|
||||||
|
requestCount += 1;
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id: "call_1",
|
||||||
|
function: {
|
||||||
|
name: "unknown_tool",
|
||||||
|
arguments: "{\"query\":\"current weather\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Done" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
runToolAwareChatCompletionsStream({
|
||||||
|
client: client as any,
|
||||||
|
model: "grok-test",
|
||||||
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["tool_call", "tool_call", "delta", "done"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||||
|
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[0]?.status, "initiated");
|
||||||
|
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||||
|
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||||
|
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[1]?.status, "failed");
|
||||||
|
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||||
|
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
});
|
||||||
|
|||||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
||||||
|
|
||||||
|
test("system prompt augmentation includes date and default location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system prompt augmentation uses provided user location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
||||||
|
const prompt = getAnthropicSystemPrompt(
|
||||||
|
[{ role: "system", content: "Use concise answers." }],
|
||||||
|
"Los Angeles, CA"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||||
|
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||||
|
assert.match(prompt, /Use concise answers\./);
|
||||||
|
});
|
||||||
25
server/tests/search-cache.test.ts
Normal file
25
server/tests/search-cache.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||||
|
|
||||||
|
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||||
|
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||||
|
assert.equal(normalizeSearchQuery(" "), null);
|
||||||
|
assert.equal(normalizeSearchQuery(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||||
|
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||||
|
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||||
|
});
|
||||||
@@ -124,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 }
|
||||||
|
|||||||
@@ -32,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;
|
||||||
@@ -171,13 +171,7 @@ function isToolCallLogMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||||
return {
|
const metadata: ToolLogMetadata = {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
|
||||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
|
||||||
role: "tool",
|
|
||||||
content: event.summary,
|
|
||||||
name: event.name,
|
|
||||||
metadata: {
|
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
toolName: event.name,
|
toolName: event.name,
|
||||||
@@ -185,12 +179,37 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
|||||||
summary: event.summary,
|
summary: event.summary,
|
||||||
args: event.args,
|
args: event.args,
|
||||||
startedAt: event.startedAt,
|
startedAt: event.startedAt,
|
||||||
completedAt: event.completedAt,
|
|
||||||
durationMs: event.durationMs,
|
|
||||||
error: event.error ?? null,
|
error: event.error ?? null,
|
||||||
resultPreview: event.resultPreview ?? null,
|
resultPreview: event.resultPreview ?? null,
|
||||||
} satisfies ToolLogMetadata,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (event.completedAt) metadata.completedAt = event.completedAt;
|
||||||
|
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
|
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||||
|
role: "tool",
|
||||||
|
content: event.summary,
|
||||||
|
name: event.name,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
|
||||||
|
const toolMessage = buildOptimisticToolMessage(event);
|
||||||
|
const existingIndex = messages.findIndex(
|
||||||
|
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantIndex = messages.findIndex(
|
||||||
|
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||||
|
);
|
||||||
|
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||||
|
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||||
@@ -602,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;
|
||||||
@@ -1083,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();
|
||||||
|
|||||||
@@ -55,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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
|
<meta name="description" content="Sybil chat and search workspace" />
|
||||||
|
<meta name="application-name" content="Sybil" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||||
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
||||||
<title>Sybil</title>
|
<title>Sybil</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
web/public/icons/favicon-32.png
Normal file
BIN
web/public/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
BIN
web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -1,9 +1,32 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Sybil",
|
"name": "Sybil",
|
||||||
"short_name": "Sybil",
|
"short_name": "Sybil",
|
||||||
|
"description": "Sybil chat and search workspace",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "fullscreen",
|
||||||
"background_color": "#ffffff",
|
"display_override": ["fullscreen", "standalone"],
|
||||||
"theme_color": "#0f172a"
|
"background_color": "#0b0718",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
web/public/sw.js
Normal file
12
web/public/sw.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.mode !== "navigate") return;
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
||||||
646
web/src/App.tsx
646
web/src/App.tsx
@@ -1,5 +1,22 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Globe2,
|
||||||
|
LoaderCircle,
|
||||||
|
Menu,
|
||||||
|
MessageSquare,
|
||||||
|
Paperclip,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Rabbit,
|
||||||
|
Search,
|
||||||
|
SendHorizontal,
|
||||||
|
Settings2,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-preact";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -18,6 +35,7 @@ import {
|
|||||||
attachSearchStream,
|
attachSearchStream,
|
||||||
getActiveRuns,
|
getActiveRuns,
|
||||||
getChat,
|
getChat,
|
||||||
|
listChatTools,
|
||||||
listModels,
|
listModels,
|
||||||
getSearch,
|
getSearch,
|
||||||
listWorkspaceItems,
|
listWorkspaceItems,
|
||||||
@@ -27,6 +45,7 @@ import {
|
|||||||
updateChatTitle,
|
updateChatTitle,
|
||||||
updateChatStar,
|
updateChatStar,
|
||||||
updateSearchStar,
|
updateSearchStar,
|
||||||
|
updateChatSettings,
|
||||||
getMessageAttachments,
|
getMessageAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ActiveRunsResponse,
|
type ActiveRunsResponse,
|
||||||
@@ -34,6 +53,7 @@ import {
|
|||||||
type Provider,
|
type Provider,
|
||||||
type ChatDetail,
|
type ChatDetail,
|
||||||
type ChatSummary,
|
type ChatSummary,
|
||||||
|
type ChatToolInfo,
|
||||||
type CompletionRequestMessage,
|
type CompletionRequestMessage,
|
||||||
type Message,
|
type Message,
|
||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
@@ -379,6 +399,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToolLabel(name: string) {
|
||||||
|
if (name === "web_search") return "Web search";
|
||||||
|
if (name === "fetch_url") return "Fetch URL";
|
||||||
|
if (name === "codex_exec") return "Codex";
|
||||||
|
if (name === "shell_exec") return "Shell";
|
||||||
|
return name
|
||||||
|
.split("_")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
|
||||||
|
return availableTools.map((tool) => tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
||||||
|
const available = new Set(availableTools.map((tool) => tool.name));
|
||||||
|
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
|
||||||
|
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||||
|
available.has(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||||
return {
|
return {
|
||||||
@@ -391,7 +435,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;
|
||||||
@@ -417,13 +461,7 @@ function isDisplayableMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||||
return {
|
const metadata: ToolLogMetadata = {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
|
||||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
|
||||||
role: "tool",
|
|
||||||
content: event.summary,
|
|
||||||
name: event.name,
|
|
||||||
metadata: {
|
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
toolName: event.name,
|
toolName: event.name,
|
||||||
@@ -431,12 +469,38 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
|||||||
summary: event.summary,
|
summary: event.summary,
|
||||||
args: event.args,
|
args: event.args,
|
||||||
startedAt: event.startedAt,
|
startedAt: event.startedAt,
|
||||||
completedAt: event.completedAt,
|
|
||||||
durationMs: event.durationMs,
|
|
||||||
error: event.error ?? null,
|
error: event.error ?? null,
|
||||||
resultPreview: event.resultPreview ?? null,
|
resultPreview: event.resultPreview ?? null,
|
||||||
} satisfies ToolLogMetadata,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (event.completedAt) metadata.completedAt = event.completedAt;
|
||||||
|
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
|
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||||
|
role: "tool",
|
||||||
|
content: event.summary,
|
||||||
|
name: event.name,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent, assistantMessagePrefix: string) {
|
||||||
|
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(assistantMessagePrefix)
|
||||||
|
);
|
||||||
|
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||||
|
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelComboboxProps = {
|
type ModelComboboxProps = {
|
||||||
@@ -748,6 +812,7 @@ export default function App() {
|
|||||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||||
const [provider, setProvider] = useState<Provider>("openai");
|
const [provider, setProvider] = useState<Provider>("openai");
|
||||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||||
|
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
||||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||||
const [model, setModel] = useState(() => {
|
const [model, setModel] = useState(() => {
|
||||||
const stored = loadStoredModelPreferences();
|
const stored = loadStoredModelPreferences();
|
||||||
@@ -774,6 +839,18 @@ export default function App() {
|
|||||||
const [renameChatDraft, setRenameChatDraft] = useState("");
|
const [renameChatDraft, setRenameChatDraft] = useState("");
|
||||||
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
||||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||||
|
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
||||||
|
const [isSavingChatSettings, setIsSavingChatSettings] = useState(false);
|
||||||
|
const [isTogglingChatSettingsStar, setIsTogglingChatSettingsStar] = useState(false);
|
||||||
|
const [chatSettingsError, setChatSettingsError] = useState<string | null>(null);
|
||||||
|
const [draftChatTitle, setDraftChatTitle] = useState("");
|
||||||
|
const [chatSettingsTitleDraft, setChatSettingsTitleDraft] = useState("");
|
||||||
|
const [chatSettingsProviderDraft, setChatSettingsProviderDraft] = useState<Provider>("openai");
|
||||||
|
const [chatSettingsModelDraft, setChatSettingsModelDraft] = useState("");
|
||||||
|
const [chatSettingsPromptDraft, setChatSettingsPromptDraft] = useState("");
|
||||||
|
const [chatSettingsEnabledToolsDraft, setChatSettingsEnabledToolsDraft] = useState<string[]>([]);
|
||||||
|
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
||||||
|
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
||||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -899,6 +976,18 @@ export default function App() {
|
|||||||
searchRunCountersRef.current.clear();
|
searchRunCountersRef.current.clear();
|
||||||
setComposer("");
|
setComposer("");
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
setIsSavingChatSettings(false);
|
||||||
|
setIsTogglingChatSettingsStar(false);
|
||||||
|
setChatSettingsError(null);
|
||||||
|
setDraftChatTitle("");
|
||||||
|
setChatSettingsTitleDraft("");
|
||||||
|
setChatSettingsProviderDraft("openai");
|
||||||
|
setChatSettingsModelDraft("");
|
||||||
|
setChatSettingsPromptDraft("");
|
||||||
|
setChatSettingsEnabledToolsDraft([]);
|
||||||
|
setAdditionalSystemPrompt("");
|
||||||
|
setEnabledTools([]);
|
||||||
setIsQuickQuestionOpen(false);
|
setIsQuickQuestionOpen(false);
|
||||||
setQuickPrompt("");
|
setQuickPrompt("");
|
||||||
setQuickSubmittedPrompt(null);
|
setQuickSubmittedPrompt(null);
|
||||||
@@ -968,6 +1057,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshChatTools = async () => {
|
||||||
|
try {
|
||||||
|
const tools = await listChatTools();
|
||||||
|
setAvailableChatTools(tools);
|
||||||
|
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshActiveRuns = async () => {
|
const refreshActiveRuns = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getActiveRuns();
|
const data = await getActiveRuns();
|
||||||
@@ -1020,7 +1124,7 @@ export default function App() {
|
|||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const preferredSelection = initialRouteSelectionRef.current;
|
const preferredSelection = initialRouteSelectionRef.current;
|
||||||
initialRouteSelectionRef.current = null;
|
initialRouteSelectionRef.current = null;
|
||||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1065,6 +1169,10 @@ export default function App() {
|
|||||||
|
|
||||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||||
|
const chatSettingsProviderModelOptions = useMemo(
|
||||||
|
() => getModelOptions(modelCatalog, chatSettingsProviderDraft),
|
||||||
|
[chatSettingsProviderDraft, modelCatalog]
|
||||||
|
);
|
||||||
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1267,11 +1375,6 @@ export default function App() {
|
|||||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||||
}, [chats, selectedItem]);
|
}, [chats, selectedItem]);
|
||||||
|
|
||||||
const selectedSidebarItem = useMemo(() => {
|
|
||||||
if (!selectedItem) return null;
|
|
||||||
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
|
|
||||||
}, [selectedItem, sidebarItems]);
|
|
||||||
|
|
||||||
const selectedSearchSummary = useMemo(() => {
|
const selectedSearchSummary = useMemo(() => {
|
||||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||||
@@ -1287,8 +1390,17 @@ export default function App() {
|
|||||||
setModel(nextSelection.model);
|
setModel(nextSelection.model);
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftKind === "chat") return;
|
||||||
|
if (selectedItem?.kind !== "chat") return;
|
||||||
|
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
||||||
|
if (!chat) return;
|
||||||
|
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
|
||||||
|
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
|
||||||
|
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
const selectedTitle = useMemo(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (draftKind === "chat") return "New chat";
|
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
|
||||||
if (draftKind === "search") return "New search";
|
if (draftKind === "search") return "New search";
|
||||||
if (!selectedItem) return "Sybil";
|
if (!selectedItem) return "Sybil";
|
||||||
if (selectedItem.kind === "chat") {
|
if (selectedItem.kind === "chat") {
|
||||||
@@ -1299,7 +1411,7 @@ export default function App() {
|
|||||||
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
||||||
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
||||||
return "New search";
|
return "New search";
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
}, [draftChatTitle, draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (draftKind || !selectedItem) return "Sybil";
|
if (draftKind || !selectedItem) return "Sybil";
|
||||||
@@ -1331,6 +1443,11 @@ export default function App() {
|
|||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
|
setDraftChatTitle("");
|
||||||
|
setAdditionalSystemPrompt("");
|
||||||
|
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
setChatSettingsError(null);
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1348,6 +1465,8 @@ export default function App() {
|
|||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
setChatSettingsError(null);
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1441,6 +1560,8 @@ export default function App() {
|
|||||||
initiatedModel: updatedChat.initiatedModel,
|
initiatedModel: updatedChat.initiatedModel,
|
||||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
lastUsedProvider: updatedChat.lastUsedProvider,
|
||||||
lastUsedModel: updatedChat.lastUsedModel,
|
lastUsedModel: updatedChat.lastUsedModel,
|
||||||
|
additionalSystemPrompt: updatedChat.additionalSystemPrompt,
|
||||||
|
enabledTools: updatedChat.enabledTools,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1476,6 +1597,99 @@ export default function App() {
|
|||||||
setRenameChatDialog({ chatId });
|
setRenameChatDialog({ chatId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getChatSettingsSeedTitle = () => {
|
||||||
|
if (draftKind === "chat") return draftChatTitle;
|
||||||
|
if (selectedItem?.kind === "chat") {
|
||||||
|
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
|
||||||
|
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
|
||||||
|
}
|
||||||
|
return draftChatTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChatSettings = () => {
|
||||||
|
if (isSearchMode) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
setRenameChatDialog(null);
|
||||||
|
setChatSettingsError(null);
|
||||||
|
setChatSettingsTitleDraft(getChatSettingsSeedTitle());
|
||||||
|
setChatSettingsProviderDraft(provider);
|
||||||
|
setChatSettingsModelDraft(model);
|
||||||
|
setChatSettingsPromptDraft(additionalSystemPrompt);
|
||||||
|
setChatSettingsEnabledToolsDraft(normalizeEnabledTools(enabledTools, availableChatTools));
|
||||||
|
setIsChatSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChatSettingsTool = (toolName: string) => {
|
||||||
|
setChatSettingsEnabledToolsDraft((current) => {
|
||||||
|
if (current.includes(toolName)) return current.filter((name) => name !== toolName);
|
||||||
|
return current.concat(toolName);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitLocalChatSettings = (nextProvider: Provider, nextModel: string, nextPrompt: string, nextTools: string[], nextTitle: string) => {
|
||||||
|
setProvider(nextProvider);
|
||||||
|
setModel(nextModel);
|
||||||
|
setProviderModelPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
[nextProvider]: nextModel || null,
|
||||||
|
}));
|
||||||
|
setAdditionalSystemPrompt(nextPrompt);
|
||||||
|
setEnabledTools(nextTools);
|
||||||
|
setDraftChatTitle(nextTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatSettingsSubmit = async (event?: Event) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (isSavingChatSettings) return;
|
||||||
|
|
||||||
|
const nextModel = chatSettingsModelDraft.trim();
|
||||||
|
if (!nextModel) {
|
||||||
|
setChatSettingsError("Enter a model.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
|
const isExistingChat = existingChatId !== null;
|
||||||
|
const nextTitle = chatSettingsTitleDraft.trim();
|
||||||
|
if (isExistingChat && !nextTitle) {
|
||||||
|
setChatSettingsError("Enter a chat title.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPrompt = chatSettingsPromptDraft.trim();
|
||||||
|
const nextTools = availableChatTools.length
|
||||||
|
? normalizeEnabledTools(chatSettingsEnabledToolsDraft, availableChatTools)
|
||||||
|
: chatSettingsEnabledToolsDraft;
|
||||||
|
|
||||||
|
setIsSavingChatSettings(true);
|
||||||
|
setChatSettingsError(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isExistingChat) {
|
||||||
|
const updatedChat = await updateChatSettings(existingChatId, {
|
||||||
|
title: nextTitle,
|
||||||
|
additionalSystemPrompt: nextPrompt || null,
|
||||||
|
...(availableChatTools.length ? { enabledTools: nextTools } : {}),
|
||||||
|
});
|
||||||
|
applyChatSummary(updatedChat);
|
||||||
|
} else if (!selectedItem && draftKind !== "chat") {
|
||||||
|
setDraftKind("chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
commitLocalChatSettings(chatSettingsProviderDraft, nextModel, nextPrompt, nextTools, nextTitle);
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else {
|
||||||
|
setChatSettingsError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSavingChatSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 176;
|
const menuWidth = 176;
|
||||||
@@ -1540,6 +1754,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleChatSettingsStar = async () => {
|
||||||
|
if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return;
|
||||||
|
const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id);
|
||||||
|
const nextStarred = !current?.starred;
|
||||||
|
setIsTogglingChatSettingsStar(true);
|
||||||
|
setChatSettingsError(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedChat = await updateChatStar(selectedItem.id, nextStarred);
|
||||||
|
applyChatSummary(updatedChat, false);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else {
|
||||||
|
setChatSettingsError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsTogglingChatSettingsStar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteFromContextMenu = async () => {
|
const handleDeleteFromContextMenu = async () => {
|
||||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||||
const target = contextMenu.item;
|
const target = contextMenu.item;
|
||||||
@@ -1588,6 +1825,17 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [renameChatDialog]);
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isChatSettingsOpen) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape" || isSavingChatSettings) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isChatSettingsOpen, isSavingChatSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -1748,9 +1996,17 @@ export default function App() {
|
|||||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
|
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
const chat = await createChat();
|
const initialEnabledTools = availableChatTools.length ? normalizeEnabledTools(enabledTools, availableChatTools) : undefined;
|
||||||
|
const chat = await createChat({
|
||||||
|
...(draftChatTitle.trim() ? { title: draftChatTitle.trim() } : {}),
|
||||||
|
provider,
|
||||||
|
model: selectedModel,
|
||||||
|
...(additionalSystemPrompt.trim() ? { additionalSystemPrompt: additionalSystemPrompt.trim() } : {}),
|
||||||
|
...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}),
|
||||||
|
});
|
||||||
chatId = chat.id;
|
chatId = chat.id;
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
|
setDraftChatTitle("");
|
||||||
setChats((current) => {
|
setChats((current) => {
|
||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
@@ -1768,6 +2024,8 @@ export default function App() {
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||||
|
enabledTools: chat.enabledTools,
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -1855,33 +2113,10 @@ export default function App() {
|
|||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
if (
|
|
||||||
pendingState.messages.some(
|
|
||||||
(message) =>
|
|
||||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolMessage = buildOptimisticToolMessage(payload);
|
|
||||||
const assistantIndex = pendingState.messages.findIndex(
|
|
||||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
|
||||||
);
|
|
||||||
if (assistantIndex < 0) {
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[chatId]: { messages: pendingState.messages.concat(toolMessage) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: [
|
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||||
...pendingState.messages.slice(0, assistantIndex),
|
|
||||||
toolMessage,
|
|
||||||
...pendingState.messages.slice(assistantIndex),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2121,30 +2356,10 @@ export default function App() {
|
|||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
if (
|
|
||||||
pendingState.messages.some(
|
|
||||||
(message) =>
|
|
||||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolMessage = buildOptimisticToolMessage(payload);
|
|
||||||
const assistantIndex = pendingState.messages.findIndex(
|
|
||||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
|
||||||
);
|
|
||||||
if (assistantIndex < 0) {
|
|
||||||
return { ...current, [chatId]: { messages: pendingState.messages.concat(toolMessage) } };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: [
|
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||||
...pendingState.messages.slice(0, assistantIndex),
|
|
||||||
toolMessage,
|
|
||||||
...pendingState.messages.slice(assistantIndex),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2349,6 +2564,8 @@ export default function App() {
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||||
|
enabledTools: chat.enabledTools,
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2409,25 +2626,7 @@ export default function App() {
|
|||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
if (
|
return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
|
||||||
current.some(
|
|
||||||
(message) =>
|
|
||||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolMessage = buildOptimisticToolMessage(payload);
|
|
||||||
const assistantIndex = current.findIndex(
|
|
||||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
|
|
||||||
);
|
|
||||||
if (assistantIndex < 0) return current.concat(toolMessage);
|
|
||||||
return [
|
|
||||||
...current.slice(0, assistantIndex),
|
|
||||||
toolMessage,
|
|
||||||
...current.slice(assistantIndex),
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
@@ -2527,6 +2726,8 @@ export default function App() {
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||||
|
enabledTools: chat.enabledTools,
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2595,6 +2796,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
|
const chatSettingsStarred = chatSettingsChatId
|
||||||
|
? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false
|
||||||
|
: false;
|
||||||
|
|
||||||
if (isCheckingSession) {
|
if (isCheckingSession) {
|
||||||
return (
|
return (
|
||||||
@@ -2617,7 +2822,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-grid-surface h-full p-0 md:p-2">
|
<div className="app-grid-surface app-safe-frame h-full">
|
||||||
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||||
{isMobileSidebarOpen ? (
|
{isMobileSidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
@@ -2773,8 +2978,8 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
|
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2788,68 +2993,24 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||||
{draftKind === null && selectedItem ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
|
||||||
onClick={() => void handleToggleStar(selectedItem)}
|
|
||||||
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
|
||||||
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
|
||||||
>
|
|
||||||
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{draftKind === null && selectedItem?.kind === "chat" ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
|
||||||
onClick={() => openRenameChatDialog(selectedItem.id)}
|
|
||||||
title="Rename chat"
|
|
||||||
aria-label="Rename chat"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<>
|
<Button
|
||||||
<select
|
type="button"
|
||||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
variant="secondary"
|
||||||
value={provider}
|
className="h-10 max-w-[44vw] gap-2 rounded-lg px-3 md:max-w-full"
|
||||||
onChange={(event) => {
|
onClick={openChatSettings}
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
|
||||||
setProvider(nextProvider);
|
|
||||||
const options = getModelOptions(modelCatalog, nextProvider);
|
|
||||||
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
|
||||||
}}
|
|
||||||
disabled={isActiveSelectionSending}
|
disabled={isActiveSelectionSending}
|
||||||
|
aria-label="Open chat settings"
|
||||||
>
|
>
|
||||||
{providerOptions.map((candidate) => (
|
<Settings2 className="h-4 w-4 shrink-0" />
|
||||||
<option key={candidate} value={candidate}>
|
<span className="hidden shrink-0 sm:inline">Settings</span>
|
||||||
{getProviderLabel(candidate)}
|
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
|
||||||
</option>
|
{getProviderLabel(provider)} · {model || "No model"}
|
||||||
))}
|
</span>
|
||||||
</select>
|
</Button>
|
||||||
<ModelCombobox
|
|
||||||
options={providerModelOptions}
|
|
||||||
value={model}
|
|
||||||
disabled={isActiveSelectionSending}
|
|
||||||
onChange={(nextModel) => {
|
|
||||||
const normalizedModel = nextModel.trim();
|
|
||||||
setModel(normalizedModel);
|
|
||||||
setProviderModelPreferences((current) => ({
|
|
||||||
...current,
|
|
||||||
[provider]: normalizedModel || null,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
||||||
<Globe2 className="mr-2 h-4 w-4" />
|
<Globe2 className="mr-2 h-4 w-4" />
|
||||||
@@ -3021,6 +3182,201 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isChatSettingsOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="chat-settings-title"
|
||||||
|
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||||
|
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
||||||
|
Chat settings
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
aria-label="Close chat settings"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||||
|
<div>
|
||||||
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={chatSettingsTitleDraft}
|
||||||
|
onInput={(event) => {
|
||||||
|
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||||
|
if (chatSettingsError) setChatSettingsError(null);
|
||||||
|
}}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
||||||
|
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
/>
|
||||||
|
{chatSettingsChatId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-11 w-11 shrink-0 rounded-lg"
|
||||||
|
onClick={() => void handleToggleChatSettingsStar()}
|
||||||
|
disabled={isSavingChatSettings || isTogglingChatSettingsStar}
|
||||||
|
aria-label={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
||||||
|
title={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
||||||
|
>
|
||||||
|
{isTogglingChatSettingsStar ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Star className={cn("h-4 w-4", chatSettingsStarred ? "fill-amber-300 text-amber-300" : "")} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(9rem,0.7fr)_minmax(14rem,1fr)]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||||
|
value={chatSettingsProviderDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
|
setChatSettingsProviderDraft(nextProvider);
|
||||||
|
const options = getModelOptions(modelCatalog, nextProvider);
|
||||||
|
setChatSettingsModelDraft(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||||
|
setChatSettingsError(null);
|
||||||
|
}}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
{providerOptions.map((candidate) => (
|
||||||
|
<option key={candidate} value={candidate}>
|
||||||
|
{getProviderLabel(candidate)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block min-w-0">
|
||||||
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||||
|
<ModelCombobox
|
||||||
|
options={chatSettingsProviderModelOptions}
|
||||||
|
value={chatSettingsModelDraft}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
onChange={(nextModel) => {
|
||||||
|
setChatSettingsModelDraft(nextModel.trim());
|
||||||
|
setChatSettingsError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Additional system prompt</span>
|
||||||
|
<Textarea
|
||||||
|
rows={5}
|
||||||
|
value={chatSettingsPromptDraft}
|
||||||
|
onInput={(event) => {
|
||||||
|
setChatSettingsPromptDraft(event.currentTarget.value);
|
||||||
|
if (chatSettingsError) setChatSettingsError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Add per-chat instructions"
|
||||||
|
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-xs font-semibold text-violet-100/72">Tools</h3>
|
||||||
|
{availableChatTools.length ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableChatTools.length ? (
|
||||||
|
availableChatTools.map((tool) => {
|
||||||
|
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={tool.name}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleChatSettingsTool(tool.name)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
||||||
|
No chat tools are available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSavingChatSettings}>
|
||||||
|
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{renameChatDialog ? (
|
{renameChatDialog ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import type { JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
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[];
|
||||||
@@ -14,7 +16,7 @@ type ToolLogMetadata = {
|
|||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
status?: "completed" | "failed";
|
status?: "initiated" | "completed" | "failed";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
@@ -71,9 +73,40 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
|||||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||||
|
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 [
|
return [
|
||||||
isFailed ? "Failed" : "Completed",
|
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||||
formatDuration(metadata.durationMs),
|
formatDuration(metadata.durationMs),
|
||||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||||
]
|
]
|
||||||
@@ -81,53 +114,293 @@ function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFaile
|
|||||||
.join(" • ");
|
.join(" • ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
function buildMessageRenderItems(messages: Message[]) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const items: MessageRenderItem[] = [];
|
||||||
|
let toolRun: Message[] = [];
|
||||||
|
|
||||||
return (
|
const flushToolRun = () => {
|
||||||
<>
|
if (!toolRun.length) return;
|
||||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
if (toolRun.length === 1) {
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
items.push({ kind: "message", message: toolRun[0] });
|
||||||
{messages.map((message) => {
|
} 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 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);
|
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||||
if (message.role === "tool" && toolLogMetadata) {
|
if (!toolLogMetadata) return null;
|
||||||
|
|
||||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||||
const isFailed = toolLogMetadata.status === "failed";
|
const toolState = getToolVisualState(toolLogMetadata);
|
||||||
|
const isFailed = toolState === "failed";
|
||||||
|
const isInitiated = toolState === "initiated";
|
||||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="flex justify-start">
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||||
isFailed
|
isFailed
|
||||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
: isInitiated
|
||||||
|
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
||||||
|
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
isFailed
|
||||||
|
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||||
|
: isInitiated
|
||||||
|
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||||
|
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 space-y-1">
|
<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")}>
|
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
|
||||||
{toolSummary}
|
|
||||||
</span>
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||||
{toolLabel}
|
{toolLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallStack({
|
||||||
|
groupKey,
|
||||||
|
messages,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
groupKey: string;
|
||||||
|
messages: Message[];
|
||||||
|
expanded: boolean;
|
||||||
|
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;
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("tool-call-stack-card-surface", !isHidden && "tool-call-stack-card-enter")}
|
||||||
|
data-tool-stack-card-id={message.id}
|
||||||
|
>
|
||||||
|
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!expanded && hiddenCount ? (
|
||||||
|
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||||
|
+{hiddenCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
aria-expanded={expanded ? "true" : "false"}
|
||||||
|
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
|
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||||
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
|
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const toggleToolGroup = (groupKey: string) => {
|
||||||
|
setExpandedToolGroups((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(groupKey)) next.delete(groupKey);
|
||||||
|
else next.add(groupKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{renderItems.map((item) => {
|
||||||
|
if (item.kind === "tool_group") {
|
||||||
|
return (
|
||||||
|
<ToolCallStack
|
||||||
|
key={`tool-group-${item.key}`}
|
||||||
|
groupKey={item.key}
|
||||||
|
messages={item.messages}
|
||||||
|
expanded={expandedToolGroups.has(item.key)}
|
||||||
|
onToggle={toggleToolGroup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = item;
|
||||||
|
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||||
|
if (message.role === "tool" && toolLogMetadata) {
|
||||||
|
return (
|
||||||
|
<div key={message.id} className="flex justify-start">
|
||||||
|
<ToolCallCard message={message} className="max-w-[85%]" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||||
|
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||||
--background: 235 45% 4%;
|
--background: 235 45% 4%;
|
||||||
--foreground: 258 36% 96%;
|
--foreground: 258 36% 96%;
|
||||||
--muted: 246 30% 13%;
|
--muted: 246 30% 13%;
|
||||||
@@ -40,6 +44,15 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -49,6 +62,8 @@ body {
|
|||||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -78,6 +93,44 @@ textarea {
|
|||||||
background-size: 48px 48px;
|
background-size: 48px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-safe-frame {
|
||||||
|
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-safe-pad {
|
||||||
|
padding:
|
||||||
|
max(1rem, var(--safe-area-top))
|
||||||
|
max(1rem, var(--safe-area-right))
|
||||||
|
max(1rem, var(--safe-area-bottom))
|
||||||
|
max(1rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-search-safe-pad {
|
||||||
|
padding:
|
||||||
|
max(1.5rem, var(--safe-area-top))
|
||||||
|
max(0.75rem, var(--safe-area-right))
|
||||||
|
max(1.5rem, var(--safe-area-bottom))
|
||||||
|
max(0.75rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.app-safe-frame {
|
||||||
|
padding:
|
||||||
|
max(0.5rem, var(--safe-area-top))
|
||||||
|
max(0.5rem, var(--safe-area-right))
|
||||||
|
max(0.5rem, var(--safe-area-bottom))
|
||||||
|
max(0.5rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-search-safe-pad {
|
||||||
|
padding:
|
||||||
|
max(1.5rem, var(--safe-area-top))
|
||||||
|
max(1.5rem, var(--safe-area-right))
|
||||||
|
max(1.5rem, var(--safe-area-bottom))
|
||||||
|
max(1.5rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||||
@@ -87,6 +140,148 @@ 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: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type ChatSummary = {
|
|||||||
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 = {
|
||||||
@@ -43,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;
|
||||||
};
|
};
|
||||||
@@ -64,6 +66,8 @@ export type ChatDetail = {
|
|||||||
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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,6 +161,11 @@ export type ModelCatalogResponse = {
|
|||||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatToolInfo = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActiveRunsResponse = {
|
export type ActiveRunsResponse = {
|
||||||
chats: string[];
|
chats: string[];
|
||||||
searches: string[];
|
searches: string[];
|
||||||
@@ -182,9 +191,23 @@ type CreateChatRequest = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
messages?: CompletionRequestMessage[];
|
messages?: CompletionRequestMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateSearchRequest = {
|
||||||
|
title?: string;
|
||||||
|
query?: string;
|
||||||
|
reuseByQuery?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateSearchResponse = {
|
||||||
|
search: SearchSummary;
|
||||||
|
reused: boolean;
|
||||||
|
cacheHit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||||
@@ -245,6 +268,11 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listChatTools() {
|
||||||
|
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
||||||
|
return data.tools;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getActiveRuns() {
|
export async function getActiveRuns() {
|
||||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||||
}
|
}
|
||||||
@@ -279,6 +307,17 @@ export async function updateChatStar(chatId: string, starred: boolean) {
|
|||||||
return data.chat;
|
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",
|
||||||
@@ -296,14 +335,22 @@ 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;
|
||||||
@@ -593,6 +640,9 @@ export async function runCompletion(body: {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
}) {
|
}) {
|
||||||
return api<CompletionResponse>("/v1/chat-completions", {
|
return api<CompletionResponse>("/v1/chat-completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -607,6 +657,9 @@ export async function runCompletionStream(
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
},
|
},
|
||||||
handlers: CompletionStreamHandlers,
|
handlers: CompletionStreamHandlers,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { render } from "preact";
|
import { render } from "preact";
|
||||||
import { RootRouter } from "@/root-router";
|
import { RootRouter } from "@/root-router";
|
||||||
|
import { registerServiceWorker } from "@/pwa";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
render(<RootRouter />, document.getElementById("app")!);
|
render(<RootRouter />, document.getElementById("app")!);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
|||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
|
||||||
function readQueryFromUrl() {
|
function readQueryFromUrl() {
|
||||||
@@ -85,14 +85,16 @@ export default function SearchRoutePage() {
|
|||||||
|
|
||||||
const runQuery = async (query: string) => {
|
const runQuery = async (query: string) => {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
|
const requestId = ++requestCounterRef.current;
|
||||||
|
streamAbortRef.current?.abort();
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setSearch(null);
|
setSearch(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsRunning(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = ++requestCounterRef.current;
|
|
||||||
streamAbortRef.current?.abort();
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
streamAbortRef.current = abortController;
|
streamAbortRef.current = abortController;
|
||||||
let wasInterrupted = false;
|
let wasInterrupted = false;
|
||||||
@@ -119,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) =>
|
||||||
@@ -140,6 +143,13 @@ export default function SearchRoutePage() {
|
|||||||
: 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,
|
||||||
{
|
{
|
||||||
@@ -252,7 +262,7 @@ export default function SearchRoutePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
<div className="app-search-safe-pad h-full overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-4xl space-y-5">
|
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||||
<form
|
<form
|
||||||
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||||
|
|||||||
9
web/src/pwa.ts
Normal file
9
web/src/pwa.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function registerServiceWorker() {
|
||||||
|
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||||
|
console.warn("Sybil service worker registration failed", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user