Compare commits
23
Commits
a0e410155d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2b0d3ca0 | ||
|
|
42022bf055 | ||
|
|
1ef491f39a | ||
|
|
b540f01759 | ||
|
|
e719cbb92d | ||
|
|
cb5a973ac7 | ||
|
|
e2443162b0 | ||
|
|
048456b8a5 | ||
|
|
211fad972a | ||
|
|
a69c641481 | ||
|
|
9229896ad7 | ||
|
|
c5217b2710 | ||
|
|
abc1124d27 | ||
|
|
4721717022 | ||
|
|
87b7d9502f | ||
|
|
1952f4f358 | ||
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 |
@@ -1,52 +1,39 @@
|
||||
name: TestFlight
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "release/ios/v*"
|
||||
- "release/ios/v*.*.*"
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
runs-on: xcode
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
name: Build and upload
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Check out the release tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Ruby
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.1.7"
|
||||
ruby-version: "3.3.11"
|
||||
bundler-cache: true
|
||||
working-directory: ios
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
||||
brew install xcodegen
|
||||
fi
|
||||
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
|
||||
- name: Upload to TestFlight
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
HOME: /var/lib/act_runner
|
||||
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY: ${{ secrets.ASC_KEY }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
|
||||
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
|
||||
CI: "true"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
|
||||
run: |
|
||||
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
|
||||
ruby --version
|
||||
bundle exec fastlane ios beta
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
run: bundle exec fastlane ios beta
|
||||
|
||||
Vendored
+26
@@ -12,17 +12,43 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://server:8787/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
|
||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||
|
||||
+57
-12
@@ -34,11 +34,13 @@ Chat upload limits:
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- `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.
|
||||
|
||||
@@ -56,7 +58,7 @@ Chat upload limits:
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
@@ -87,6 +89,8 @@ Behavior notes:
|
||||
"type": "chat",
|
||||
"id": "chat-id",
|
||||
"title": "optional title",
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": true,
|
||||
@@ -115,7 +119,8 @@ Behavior notes:
|
||||
Behavior notes:
|
||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||
- The response currently combines up to 100 chats and up to 100 searches.
|
||||
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
## Chats
|
||||
@@ -128,7 +133,7 @@ Behavior notes:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
@@ -146,15 +151,32 @@ Behavior notes:
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
|
||||
- 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.
|
||||
|
||||
### `POST /v1/chats/:chatId/fork`
|
||||
- Body: `{ "messageId"?: string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Source chat not found: `404 { "message": "chat not found" }`
|
||||
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
|
||||
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
|
||||
|
||||
Behavior notes:
|
||||
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
||||
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
||||
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
||||
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
||||
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
||||
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
|
||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||
@@ -181,13 +203,19 @@ Behavior notes:
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
Behavior notes:
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
||||
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
|
||||
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
|
||||
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
|
||||
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
|
||||
|
||||
### `GET /v1/chats/:chatId`
|
||||
- Response: `{ "chat": ChatDetail }`
|
||||
@@ -234,7 +262,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -294,13 +322,16 @@ Behavior notes:
|
||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
|
||||
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
|
||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `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), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||
@@ -318,6 +349,16 @@ Behavior notes:
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
|
||||
## Streaming Chat
|
||||
|
||||
### `POST /v1/chat-completions/stream`
|
||||
|
||||
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
|
||||
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
|
||||
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
|
||||
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
|
||||
|
||||
## Searches
|
||||
|
||||
### `GET /v1/searches`
|
||||
@@ -413,13 +454,15 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
@@ -465,13 +508,15 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
|
||||
@@ -21,7 +21,8 @@ Authentication:
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"clientRequestId": "optional-client-generated-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -61,6 +62,9 @@ Notes:
|
||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||
- 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.
|
||||
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
|
||||
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
|
||||
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||
- 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.
|
||||
@@ -69,8 +73,10 @@ Notes:
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
|
||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||
|
||||
## Attach Endpoint
|
||||
@@ -174,18 +180,21 @@ Terminal tool-call event:
|
||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
|
||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||
- Stream may emit `tool_call` events while tool calls are executed.
|
||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
|
||||
+5
-24
@@ -1,24 +1,5 @@
|
||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||
FASTLANE_HIDE_CHANGELOG=1
|
||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
||||
SYBIL_PROVISIONING_PROFILE_UUID=
|
||||
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
|
||||
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
|
||||
SYBIL_SIGNING_CERTIFICATE_ID=
|
||||
SYBIL_SIGNING_KEYCHAIN=
|
||||
|
||||
# App Store Connect API key settings for TestFlight upload and signing setup.
|
||||
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=
|
||||
ASC_KEY_ID=
|
||||
ASC_ISSUER_ID=
|
||||
ASC_KEY=
|
||||
MATCH_PASSWORD=
|
||||
MATCH_GIT_BASIC_AUTHORIZATION=
|
||||
|
||||
@@ -57,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- OpenAI: `gpt-4.1-mini`
|
||||
- Anthropic: `claude-3-5-sonnet-latest`
|
||||
- xAI: `grok-3-mini`
|
||||
- Gemini: `gemini-3.5-flash`
|
||||
- Hermes Agent: `hermes-agent`
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "fastlane", "2.237.0"
|
||||
|
||||
+75
-62
@@ -1,46 +1,49 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.9)
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1274.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-s3 (1.228.1)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.109.0)
|
||||
excon (1.6.0)
|
||||
logger
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -70,42 +73,45 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@@ -120,41 +126,46 @@ GEM
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.65.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
@@ -162,22 +173,24 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.7.6)
|
||||
jwt (2.10.3)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (5.1.1)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -189,11 +202,10 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.18.0)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
@@ -206,15 +218,16 @@ GEM
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
@@ -225,7 +238,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
fastlane (= 2.237.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
|
||||
@@ -67,7 +67,6 @@ struct SybilChatTranscriptView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
syncKnownToolCallMessageIDs()
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
}
|
||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||
syncKnownToolCallMessageIDs()
|
||||
|
||||
@@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case openai
|
||||
case anthropic
|
||||
case xai
|
||||
case gemini
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case .openai: return "OpenAI"
|
||||
case .anthropic: return "Anthropic"
|
||||
case .xai: return "xAI"
|
||||
case .gemini: return "Gemini"
|
||||
case .hermesAgent: return "Hermes Agent"
|
||||
}
|
||||
}
|
||||
@@ -152,6 +154,8 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -162,6 +166,39 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var lastUsedModel: String?
|
||||
}
|
||||
|
||||
extension ChatSummary {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
@@ -182,6 +219,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -196,6 +235,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
self.id = chat.id
|
||||
self.title = chat.title
|
||||
self.query = nil
|
||||
self.parentChatId = chat.parentChatId
|
||||
self.titleGenerationPending = chat.titleGenerationPending
|
||||
self.createdAt = chat.createdAt
|
||||
self.updatedAt = chat.updatedAt
|
||||
self.starred = chat.starred
|
||||
@@ -211,6 +252,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
self.id = search.id
|
||||
self.title = search.title
|
||||
self.query = search.query
|
||||
self.parentChatId = nil
|
||||
self.titleGenerationPending = false
|
||||
self.createdAt = search.createdAt
|
||||
self.updatedAt = search.updatedAt
|
||||
self.starred = search.starred
|
||||
@@ -226,6 +269,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
return ChatSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
parentChatId: parentChatId,
|
||||
titleGenerationPending: titleGenerationPending,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
@@ -251,6 +296,43 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkspaceItem {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case id
|
||||
case title
|
||||
case query
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
type = try container.decode(WorkspaceItemType.self, forKey: .type)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
query = try container.decodeIfPresent(String.self, forKey: .query)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var createdAt: Date
|
||||
@@ -389,6 +471,8 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -400,6 +484,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var messages: [Message]
|
||||
}
|
||||
|
||||
extension ChatDetail {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
case messages
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
messages = try container.decode([Message].self, forKey: .messages)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var createdAt: Date
|
||||
|
||||
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
|
||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ final class SybilSettingsStore {
|
||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
|
||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||
]
|
||||
self.preferredModelByProvider = preferredModels
|
||||
@@ -54,6 +57,7 @@ final class SybilSettingsStore {
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||
]
|
||||
}
|
||||
@@ -72,12 +76,14 @@ final class SybilSettingsStore {
|
||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||
|
||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ final class SybilViewModel {
|
||||
.openai: ["gpt-4.1-mini"],
|
||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||
.xai: ["grok-3-mini"],
|
||||
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
.hermesAgent: ["hermes-agent"]
|
||||
]
|
||||
|
||||
@@ -405,14 +406,17 @@ final class SybilViewModel {
|
||||
} else {
|
||||
initiatedLabel = nil
|
||||
}
|
||||
let starOwner = item.parentChatId.flatMap { parentChatID in
|
||||
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
|
||||
} ?? item
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(item.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: item.title, messages: nil),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
starred: starOwner.starred,
|
||||
starredAt: starOwner.starredAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
)
|
||||
@@ -686,6 +690,8 @@ final class SybilViewModel {
|
||||
selectedChat = ChatDetail(
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
parentChatId: chat.parentChatId,
|
||||
titleGenerationPending: chat.titleGenerationPending,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
@@ -895,7 +901,8 @@ final class SybilViewModel {
|
||||
let client = try client()
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||
let rootChatID = chatFamilyRootID(for: chatID)
|
||||
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
||||
applyChatSummary(updated, moveToFront: false)
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
@@ -1453,6 +1460,8 @@ final class SybilViewModel {
|
||||
|
||||
if selectedChat?.id == chat.id {
|
||||
selectedChat?.title = chat.title
|
||||
selectedChat?.parentChatId = chat.parentChatId
|
||||
selectedChat?.titleGenerationPending = chat.titleGenerationPending
|
||||
selectedChat?.updatedAt = chat.updatedAt
|
||||
selectedChat?.starred = chat.starred
|
||||
selectedChat?.starredAt = chat.starredAt
|
||||
@@ -1504,6 +1513,19 @@ final class SybilViewModel {
|
||||
workspaceItems.insert(item, at: 0)
|
||||
}
|
||||
|
||||
private func chatFamilyRootID(for chatID: String) -> String {
|
||||
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
return chatID
|
||||
}
|
||||
|
||||
private func attachToVisibleActiveRunIfNeeded() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1751,13 +1773,16 @@ final class SybilViewModel {
|
||||
switch target {
|
||||
case let .chat(chatID):
|
||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||
let isSelectingDifferentChat = selectedChat?.id != chatID
|
||||
let chat = try await client.getChat(chatID: chatID)
|
||||
guard selectedItem == target, draftKind == nil else {
|
||||
return
|
||||
}
|
||||
selectedChat = chat
|
||||
selectedSearch = nil
|
||||
requestChatBottomPin()
|
||||
if isSelectingDifferentChat {
|
||||
requestChatBottomPin()
|
||||
}
|
||||
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
@@ -1850,6 +1875,8 @@ final class SybilViewModel {
|
||||
selectedChat = ChatDetail(
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
parentChatId: created.parentChatId,
|
||||
titleGenerationPending: created.titleGenerationPending,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
@@ -1908,7 +1935,7 @@ final class SybilViewModel {
|
||||
let streamLifecycleGeneration = appLifecycleGeneration
|
||||
let streamStartedWhileInactive = !isAppActive
|
||||
|
||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||
if shouldRequestChatTitle(baseChat) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
@@ -2619,20 +2646,10 @@ final class SybilViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
||||
if let detail, detail.id == chatID {
|
||||
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return false
|
||||
}
|
||||
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
||||
if chat.titleGenerationPending {
|
||||
return true
|
||||
}
|
||||
|
||||
if let summary = chats.first(where: { $0.id == chatID }) {
|
||||
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ private struct MockClientCallSnapshot: Sendable {
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var updateChatTitle = 0
|
||||
var suggestChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
var lastUpdateChatStarID: String?
|
||||
var lastUpdateChatStarred: Bool?
|
||||
var updateSearchStar = 0
|
||||
var getSearch = 0
|
||||
var getActiveRuns = 0
|
||||
@@ -36,6 +39,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
private let updateSearchStarResponses: [String: SearchSummary]
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
@@ -64,6 +68,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
@@ -76,6 +81,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
self.updateSearchStarResponses = updateSearchStarResponses
|
||||
self.activeRunsResponse = activeRunsResponse
|
||||
@@ -204,6 +210,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
snapshot.updateChatStar += 1
|
||||
snapshot.lastUpdateChatStarID = chatID
|
||||
snapshot.lastUpdateChatStarred = starred
|
||||
guard let summary = updateChatStarResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -215,7 +223,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
}
|
||||
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||
throw UnexpectedClientCall()
|
||||
snapshot.suggestChatTitle += 1
|
||||
guard let summary = suggestChatTitleResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
@@ -420,6 +432,46 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
)
|
||||
}
|
||||
|
||||
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
||||
let decoder = JSONDecoder()
|
||||
let legacySummary = try decoder.decode(
|
||||
ChatSummary.self,
|
||||
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
||||
)
|
||||
let legacyWorkspaceItem = try decoder.decode(
|
||||
WorkspaceItem.self,
|
||||
from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
||||
)
|
||||
let legacyDetail = try decoder.decode(
|
||||
ChatDetail.self,
|
||||
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
||||
)
|
||||
let forkDetail = try decoder.decode(
|
||||
ChatDetail.self,
|
||||
from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
||||
)
|
||||
|
||||
#expect(legacySummary.parentChatId == nil)
|
||||
#expect(!legacySummary.titleGenerationPending)
|
||||
#expect(legacyWorkspaceItem.parentChatId == nil)
|
||||
#expect(!legacyWorkspaceItem.titleGenerationPending)
|
||||
#expect(legacyDetail.parentChatId == nil)
|
||||
#expect(!legacyDetail.titleGenerationPending)
|
||||
#expect(forkDetail.parentChatId == "root-chat")
|
||||
#expect(forkDetail.titleGenerationPending)
|
||||
|
||||
var fork = legacySummary
|
||||
fork.parentChatId = "root-chat"
|
||||
fork.titleGenerationPending = true
|
||||
|
||||
let workspaceItem = WorkspaceItem(chat: fork)
|
||||
let restoredSummary = try #require(workspaceItem.chatSummary)
|
||||
#expect(workspaceItem.parentChatId == "root-chat")
|
||||
#expect(workspaceItem.titleGenerationPending)
|
||||
#expect(restoredSummary.parentChatId == "root-chat")
|
||||
#expect(restoredSummary.titleGenerationPending)
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -544,12 +596,14 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
@MainActor
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
let staleDetail = makeChatDetail(id: "chat-2", date: date, body: "stale transcript")
|
||||
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
||||
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-2")
|
||||
viewModel.selectedChat = staleDetail
|
||||
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
@@ -559,24 +613,22 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||
#expect(viewModel.chatBottomPinRequestID == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
||||
let renamed = ChatSummary(
|
||||
id: "chat-rename",
|
||||
title: "Renamed chat",
|
||||
createdAt: date,
|
||||
updatedAt: date.addingTimeInterval(60),
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini"
|
||||
)
|
||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||
var original = makeChatSummary(id: "chat-rename", date: date)
|
||||
original.parentChatId = "root-chat"
|
||||
original.titleGenerationPending = true
|
||||
var renamed = original
|
||||
renamed.title = "Renamed chat"
|
||||
renamed.titleGenerationPending = false
|
||||
renamed.updatedAt = date.addingTimeInterval(60)
|
||||
var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||
detail.parentChatId = original.parentChatId
|
||||
detail.titleGenerationPending = original.titleGenerationPending
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [original],
|
||||
updateChatTitleResponses: ["chat-rename": renamed]
|
||||
@@ -594,7 +646,13 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatTitle == 1)
|
||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||
#expect(viewModel.chats.first?.parentChatId == "root-chat")
|
||||
#expect(viewModel.chats.first?.titleGenerationPending == false)
|
||||
#expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
|
||||
#expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
|
||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||
#expect(viewModel.selectedChat?.parentChatId == "root-chat")
|
||||
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@@ -633,6 +691,54 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_180)
|
||||
var root = makeChatSummary(id: "chat-root", date: date)
|
||||
root.starred = true
|
||||
root.starredAt = date.addingTimeInterval(5)
|
||||
|
||||
var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
|
||||
child.parentChatId = root.id
|
||||
child.titleGenerationPending = true
|
||||
|
||||
var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
|
||||
childDetail.title = child.title
|
||||
childDetail.parentChatId = root.id
|
||||
childDetail.titleGenerationPending = true
|
||||
|
||||
var unstarredRoot = root
|
||||
unstarredRoot.starred = false
|
||||
unstarredRoot.starredAt = nil
|
||||
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [root, child],
|
||||
updateChatStarResponses: [root.id: unstarredRoot]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [root, child]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
|
||||
viewModel.selectedItem = .chat(child.id)
|
||||
viewModel.selectedChat = childDetail
|
||||
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)
|
||||
|
||||
await viewModel.setItemStarred(.chat(child.id), starred: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatStar == 1)
|
||||
#expect(snapshot.lastUpdateChatStarID == root.id)
|
||||
#expect(snapshot.lastUpdateChatStarred == false)
|
||||
#expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
|
||||
#expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
|
||||
#expect(viewModel.selectedItem == .chat(child.id))
|
||||
#expect(viewModel.selectedChat == childDetail)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||
@@ -675,6 +781,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
|
||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -778,6 +885,72 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_247)
|
||||
var fork = makeChatSummary(id: "chat-fork", date: date)
|
||||
fork.title = "Fork of Original chat"
|
||||
fork.parentChatId = "root-chat"
|
||||
fork.titleGenerationPending = true
|
||||
|
||||
var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
|
||||
forkDetail.title = fork.title
|
||||
forkDetail.parentChatId = fork.parentChatId
|
||||
forkDetail.titleGenerationPending = true
|
||||
|
||||
var titledFork = fork
|
||||
titledFork.title = "Investigating the follow-up"
|
||||
titledFork.titleGenerationPending = false
|
||||
titledFork.updatedAt = date.addingTimeInterval(1)
|
||||
|
||||
var titledForkDetail = forkDetail
|
||||
titledForkDetail.title = titledFork.title
|
||||
titledForkDetail.titleGenerationPending = false
|
||||
titledForkDetail.updatedAt = titledFork.updatedAt
|
||||
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [titledFork],
|
||||
chatDetails: [fork.id: titledForkDetail],
|
||||
suggestChatTitleResponses: [fork.id: titledFork]
|
||||
)
|
||||
await client.setCompletionStreamEvents(
|
||||
[.done(CompletionStreamDone(text: "Follow-up answer"))],
|
||||
delayNanoseconds: 100_000_000
|
||||
)
|
||||
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [fork]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
|
||||
viewModel.selectedItem = .chat(fork.id)
|
||||
viewModel.selectedChat = forkDetail
|
||||
viewModel.composer = "Investigate this follow-up"
|
||||
|
||||
let sendTask = Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
|
||||
for _ in 0..<20 {
|
||||
let snapshot = await client.currentSnapshot()
|
||||
if snapshot.suggestChatTitle == 1,
|
||||
viewModel.selectedChat?.title == titledFork.title,
|
||||
viewModel.selectedChat?.titleGenerationPending == false {
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
|
||||
let titleSnapshot = await client.currentSnapshot()
|
||||
#expect(titleSnapshot.suggestChatTitle == 1)
|
||||
#expect(viewModel.chats.first?.title == titledFork.title)
|
||||
#expect(viewModel.workspaceItems.first?.title == titledFork.title)
|
||||
#expect(viewModel.selectedChat?.title == titledFork.title)
|
||||
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
||||
|
||||
await sendTask.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||
let client = MockSybilClient()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("net.buzzert.sybil2")
|
||||
team_id("DQQH5H6GBD")
|
||||
+33
-155
@@ -1,169 +1,47 @@
|
||||
require "shellwords"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "net.buzzert.sybil2"
|
||||
SCHEME = "Sybil"
|
||||
TEAM_ID = "DQQH5H6GBD"
|
||||
PROFILE_NAME = "Sybil AppStore CI"
|
||||
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
|
||||
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
|
||||
CI_KEYCHAIN_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}")
|
||||
CI_KEYCHAIN_DB_PATH = "#{CI_KEYCHAIN_PATH}-db"
|
||||
IOS_ROOT = File.expand_path("..", __dir__)
|
||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||
|
||||
def present?(value)
|
||||
!value.to_s.strip.empty?
|
||||
end
|
||||
|
||||
def ci?
|
||||
present?(ENV["CI"])
|
||||
end
|
||||
|
||||
def release_version
|
||||
tag = ENV["SYBIL_VERSION_TAG"]
|
||||
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
|
||||
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
|
||||
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
|
||||
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
|
||||
|
||||
unless match
|
||||
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
|
||||
end
|
||||
|
||||
match[1]
|
||||
end
|
||||
|
||||
# App Store Connect requires CFBundleVersion to be unique and strictly
|
||||
# increasing app-wide (not just per marketing version), so we derive it from
|
||||
# the monotonic CI run number rather than querying TestFlight (that query can
|
||||
# lag behind builds still processing and hand back a colliding value).
|
||||
def build_number
|
||||
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
|
||||
|
||||
unless value.to_s.match?(/\A\d+\z/)
|
||||
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
|
||||
end
|
||||
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def stamp_marketing_version(version)
|
||||
contents = File.read(APP_PROJECT_SPEC)
|
||||
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||
|
||||
if updated == contents
|
||||
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
|
||||
end
|
||||
|
||||
File.write(APP_PROJECT_SPEC, updated)
|
||||
end
|
||||
|
||||
def ci_keychain_path
|
||||
File.file?(CI_KEYCHAIN_DB_PATH) ? CI_KEYCHAIN_DB_PATH : CI_KEYCHAIN_PATH
|
||||
end
|
||||
|
||||
def signing_xcargs
|
||||
args = [
|
||||
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
|
||||
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
|
||||
]
|
||||
|
||||
if ci?
|
||||
args << "CODE_SIGN_KEYCHAIN=#{ci_keychain_path.shellescape}"
|
||||
args << "OTHER_CODE_SIGN_FLAGS=#{("--keychain #{ci_keychain_path}").shellescape}"
|
||||
end
|
||||
|
||||
args.join(" ")
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
private_lane :app_store_api_key do
|
||||
app_store_connect_api_key(
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
|
||||
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
|
||||
desc "Build a release tag and upload it to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci
|
||||
|
||||
match(type: "appstore")
|
||||
|
||||
tag = ENV.fetch("GITHUB_REF_NAME")
|
||||
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
||||
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
|
||||
|
||||
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
|
||||
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
ios_dir = File.expand_path("..", __dir__)
|
||||
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
|
||||
contents = File.read(project_spec)
|
||||
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
|
||||
UI.user_error!("Could not find version settings in #{project_spec}")
|
||||
end
|
||||
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
|
||||
File.write(project_spec, contents)
|
||||
|
||||
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
|
||||
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_content: ENV.fetch("ASC_KEY"),
|
||||
is_key_content_base64: true
|
||||
)
|
||||
end
|
||||
|
||||
# CI uses a dedicated throwaway keychain for match. build_app passes this
|
||||
# keychain explicitly so codesign does not depend on the runner's ambient
|
||||
# login/default keychain state.
|
||||
private_lane :prepare_ci_keychain do
|
||||
next unless ci?
|
||||
|
||||
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH) || File.file?(CI_KEYCHAIN_PATH)
|
||||
create_keychain(
|
||||
name: CI_KEYCHAIN_NAME,
|
||||
password: CI_KEYCHAIN_PASSWORD,
|
||||
unlock: true,
|
||||
timeout: 3600,
|
||||
add_to_search_list: true
|
||||
)
|
||||
|
||||
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
|
||||
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
|
||||
end
|
||||
|
||||
private_lane :sync_signing do |options|
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: options.fetch(:readonly),
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
team_id: TEAM_ID,
|
||||
profile_name: PROFILE_NAME,
|
||||
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||
git_branch: "master",
|
||||
git_full_name: "Sybil Release Bot",
|
||||
git_user_email: "james.magahern@me.com",
|
||||
api_key: options.fetch(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
desc "Create or update match signing assets"
|
||||
lane :setup_signing do
|
||||
sync_signing(api_key: app_store_api_key, readonly: false)
|
||||
end
|
||||
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
prepare_ci_keychain
|
||||
|
||||
api_key = app_store_api_key
|
||||
|
||||
version = release_version
|
||||
stamp_marketing_version(version)
|
||||
sh("xcodegen", "--spec", PROJECT_SPEC)
|
||||
|
||||
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
|
||||
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
|
||||
|
||||
sync_signing(api_key: api_key, readonly: true)
|
||||
|
||||
build_app(
|
||||
project: PROJECT_FILE,
|
||||
scheme: SCHEME,
|
||||
export_method: "app-store",
|
||||
codesigning_identity: "Apple Distribution",
|
||||
xcargs: signing_xcargs,
|
||||
export_options: {
|
||||
signingStyle: "manual",
|
||||
teamID: TEAM_ID,
|
||||
provisioningProfiles: {
|
||||
APP_IDENTIFIER => PROFILE_NAME
|
||||
}
|
||||
}
|
||||
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
||||
scheme: "Sybil"
|
||||
)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
|
||||
app_identifier(["net.buzzert.sybil2"])
|
||||
team_id("DQQH5H6GBD")
|
||||
profile_name("Sybil AppStore CI")
|
||||
+6
-4
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
Backend API for:
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
||||
- Personal chat database (chats/messages + LLM call log)
|
||||
|
||||
## Stack
|
||||
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `OPENAI_API_KEY`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `XAI_API_KEY`
|
||||
- `GEMINI_API_KEY`
|
||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||
- `EXA_API_KEY`
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
|
||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)
|
||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
||||
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||
@@ -58,7 +60,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||
|
||||
## API
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add durable root grouping and one-time fork title generation state.
|
||||
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
||||
@@ -13,6 +13,7 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
gemini
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
@@ -50,7 +51,8 @@ model Chat {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
title String?
|
||||
title String?
|
||||
titleGenerationPending Boolean @default(false)
|
||||
|
||||
initiatedProvider Provider?
|
||||
initiatedModel String?
|
||||
@@ -63,11 +65,17 @@ model Chat {
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
// Forks always point directly to the single root chat, never to another fork.
|
||||
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
||||
parentChatId String?
|
||||
childChats Chat[] @relation("ChatForks")
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([parentChatId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
|
||||
+11
-1
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
},
|
||||
z.enum(["exa", "searxng"]).default("exa")
|
||||
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||
);
|
||||
|
||||
const BooleanFlagSchema = z.preprocess((value) => {
|
||||
@@ -66,10 +66,12 @@ const EnvSchema = z.object({
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
XAI_API_KEY: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||
@@ -99,6 +101,14 @@ const EnvSchema = z.object({
|
||||
});
|
||||
}
|
||||
|
||||
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["BRAVE_SEARCH_API_KEY"],
|
||||
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
|
||||
});
|
||||
}
|
||||
|
||||
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { convert as htmlToText } from "html-to-text";
|
||||
import { z } from "zod";
|
||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
import { searchBrave } from "../search/brave.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
@@ -507,11 +508,33 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
|
||||
};
|
||||
}
|
||||
|
||||
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
const response = await searchBrave(args.query, {
|
||||
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
||||
includeDomains: args.includeDomains,
|
||||
excludeDomains: args.excludeDomains,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
searchEngine: "brave",
|
||||
query: args.query,
|
||||
requestId: response.requestId,
|
||||
results: response.results.map((result, index) => ({
|
||||
rank: index + 1,
|
||||
...result,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
||||
const args = WebSearchArgsSchema.parse(input);
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
||||
return runSearxngWebSearchTool(args);
|
||||
}
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
|
||||
return runBraveWebSearchTool(args);
|
||||
}
|
||||
return runExaWebSearchTool(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import {
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildTextAttachmentPrompt,
|
||||
buildTopLevelSystemPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
parseImageDataUrl,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
type GeminiClient = {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
function normalizeModelResourceName(model: string) {
|
||||
const trimmed = model.trim().replace(/^\/+/, "");
|
||||
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
|
||||
}
|
||||
|
||||
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
|
||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
|
||||
url.searchParams.set("key", client.apiKey);
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
||||
const config: Record<string, unknown> = {};
|
||||
if (params.temperature !== undefined) config.temperature = params.temperature;
|
||||
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
|
||||
return Object.keys(config).length ? config : undefined;
|
||||
}
|
||||
|
||||
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
||||
const input = schema as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
if (typeof input.type === "string") output.type = input.type;
|
||||
if (typeof input.description === "string") output.description = input.description;
|
||||
if (typeof input.format === "string") output.format = input.format;
|
||||
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
|
||||
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
|
||||
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
|
||||
|
||||
const items = toGeminiJsonSchema(input.items);
|
||||
if (items) output.items = items;
|
||||
|
||||
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input.properties)) {
|
||||
const propertySchema = toGeminiJsonSchema(value);
|
||||
if (propertySchema) properties[key] = propertySchema;
|
||||
}
|
||||
if (Object.keys(properties).length) output.properties = properties;
|
||||
}
|
||||
|
||||
return Object.keys(output).length ? output : undefined;
|
||||
}
|
||||
|
||||
function toGeminiTools(tools: any[]) {
|
||||
const functionDeclarations = tools
|
||||
.map((tool) => {
|
||||
if (tool?.type !== "function") return null;
|
||||
const declaration: Record<string, unknown> = {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
};
|
||||
const parameters = toGeminiJsonSchema(tool.function.parameters);
|
||||
if (parameters) declaration.parameters = parameters;
|
||||
return declaration;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
|
||||
}
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ text: message.content });
|
||||
}
|
||||
|
||||
return parts.length ? parts : [{ text: "" }];
|
||||
}
|
||||
|
||||
function buildConversationContent(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for Gemini.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: toContentParts(message),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseContents(messages: ChatMessage[]) {
|
||||
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
|
||||
}
|
||||
|
||||
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
|
||||
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
|
||||
return text ? { parts: [{ text }] } : undefined;
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
const normalized = normalizeUsage(usage);
|
||||
if (!normalized) return false;
|
||||
acc.inputTokens += normalized.inputTokens;
|
||||
acc.outputTokens += normalized.outputTokens;
|
||||
acc.totalTokens += normalized.totalTokens;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeUsage(usage: any) {
|
||||
if (!usage) return null;
|
||||
const inputTokens = usage.promptTokenCount ?? 0;
|
||||
const outputTokens = usage.candidatesTokenCount ?? 0;
|
||||
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
|
||||
return { inputTokens, outputTokens, totalTokens };
|
||||
}
|
||||
|
||||
function getCandidate(response: any) {
|
||||
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
|
||||
}
|
||||
|
||||
function getParts(response: any) {
|
||||
const parts = getCandidate(response)?.content?.parts;
|
||||
return Array.isArray(parts) ? parts : [];
|
||||
}
|
||||
|
||||
function extractText(response: any) {
|
||||
return getParts(response)
|
||||
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function stringifyToolArgs(args: unknown) {
|
||||
try {
|
||||
return JSON.stringify(args ?? {});
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
|
||||
return parts
|
||||
.filter((part) => part?.functionCall)
|
||||
.map((part, index) => ({
|
||||
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
|
||||
name: part.functionCall.name ?? "unknown_tool",
|
||||
arguments: stringifyToolArgs(part.functionCall.args),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
|
||||
return {
|
||||
functionResponse: {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
response: toolResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function appendCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "model", parts: [{ text }] });
|
||||
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
|
||||
}
|
||||
|
||||
async function parseGeminiResponse(response: Response) {
|
||||
const bodyText = await response.text();
|
||||
let body: any = null;
|
||||
try {
|
||||
body = bodyText ? JSON.parse(bodyText) : null;
|
||||
} catch {
|
||||
body = { raw: bodyText };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseGeminiResponse(response);
|
||||
}
|
||||
|
||||
function getFailureMessage(response: any, text: string, toolCallCount: number) {
|
||||
const promptBlockReason = response?.promptFeedback?.blockReason;
|
||||
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
|
||||
|
||||
const candidate = getCandidate(response);
|
||||
const finishReason = candidate?.finishReason;
|
||||
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
|
||||
if (text || toolCallCount > 0) return null;
|
||||
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
|
||||
}
|
||||
|
||||
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
|
||||
const tools = toGeminiTools(enabledTools);
|
||||
return {
|
||||
contents: conversation,
|
||||
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
|
||||
generationConfig: generationConfig(params),
|
||||
tools,
|
||||
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation = buildBaseContents(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await generateContent(params, buildRequest(params, conversation, enabledTools));
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
|
||||
|
||||
const parts = getParts(response);
|
||||
const text = extractText(response);
|
||||
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
|
||||
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({ role: "model", parts });
|
||||
|
||||
const toolResultParts: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({ role: "user", parts: toolResultParts });
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function findSseBoundary(buffer: string) {
|
||||
const crlf = buffer.indexOf("\r\n\r\n");
|
||||
const lf = buffer.indexOf("\n\n");
|
||||
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
||||
if (lf === -1) return { index: crlf, length: 4 };
|
||||
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
||||
}
|
||||
|
||||
function parseSseEvent(rawEvent: string) {
|
||||
const data = rawEvent
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart())
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!data || data === "[DONE]") return null;
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await parseGeminiResponse(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Gemini stream response did not include a body.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary = findSseBoundary(buffer);
|
||||
while (boundary) {
|
||||
const rawEvent = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const event = parseSseEvent(rawEvent);
|
||||
if (event) yield event;
|
||||
boundary = findSseBoundary(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
const event = parseSseEvent(tail);
|
||||
if (event) yield event;
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation = buildBaseContents(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
if (!enabledTools.length) {
|
||||
let text = "";
|
||||
let latestUsage: any = null;
|
||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
|
||||
rawResponses.push(response);
|
||||
if (response?.usageMetadata) latestUsage = response.usageMetadata;
|
||||
const failureMessage = getFailureMessage(response, extractText(response), 0);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
const delta = extractText(response);
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
|
||||
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const roundParts: any[] = [];
|
||||
let roundText = "";
|
||||
let latestRoundResponse: any = null;
|
||||
let latestRoundUsage: any = null;
|
||||
|
||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
|
||||
rawResponses.push(response);
|
||||
latestRoundResponse = response;
|
||||
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
|
||||
roundParts.push(...getParts(response));
|
||||
roundText += extractText(response);
|
||||
}
|
||||
|
||||
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
|
||||
|
||||
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
|
||||
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, "");
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({ role: "model", parts: roundParts });
|
||||
|
||||
const toolResultParts: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({ role: "user", parts: toolResultParts });
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: {
|
||||
streamed: true,
|
||||
responses: rawResponses,
|
||||
toolCallsUsed: totalToolCalls,
|
||||
toolCallLimitReached: true,
|
||||
api: "gemini.streamGenerateContent",
|
||||
},
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||
import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import type { ChatMessage, Provider } from "./types.js";
|
||||
|
||||
type ProviderAdapterParams = {
|
||||
@@ -27,7 +28,7 @@ export type ProviderChatAdapter = {
|
||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||
|
||||
type ChatProtocol = {
|
||||
id: ChatProtocolId;
|
||||
@@ -39,6 +40,7 @@ type ModelCatalogSpec = {
|
||||
enabled?: () => boolean;
|
||||
fetchModels(client: any): Promise<string[]>;
|
||||
fallbackModels?: () => string[];
|
||||
sortModels?: (models: string[]) => string[];
|
||||
};
|
||||
|
||||
type ProviderBackendSpec = {
|
||||
@@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = {
|
||||
stream: streamWithMessagesApi,
|
||||
};
|
||||
|
||||
const geminiProtocol: ChatProtocol = {
|
||||
id: "gemini",
|
||||
complete: completeWithGeminiApi,
|
||||
stream: streamWithGeminiApi,
|
||||
};
|
||||
|
||||
const responsesProtocol: ChatProtocol = {
|
||||
id: "responses",
|
||||
complete: completeWithResponsesApi,
|
||||
@@ -77,6 +85,10 @@ function modelIdsFromListResponse(page: any) {
|
||||
: [];
|
||||
}
|
||||
|
||||
function stripModelResourcePrefix(model: string) {
|
||||
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||
}
|
||||
|
||||
function isLikelyResponsesApiModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
@@ -86,6 +98,37 @@ function isLikelyResponsesApiModel(model: string) {
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function isLikelyGeminiChatModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (!id.startsWith("gemini-")) return false;
|
||||
if (id.includes("embedding") || id.includes("embed")) return false;
|
||||
if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false;
|
||||
if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false;
|
||||
if (id.includes("computer-use") || id.includes("robotics")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function preferGeminiModels(models: string[]) {
|
||||
const preferred = [
|
||||
"gemini-3.5-flash",
|
||||
"gemini-flash-latest",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-pro-latest",
|
||||
];
|
||||
const modelSet = new Set(models);
|
||||
return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))];
|
||||
}
|
||||
|
||||
async function fetchJson(url: URL): Promise<any> {
|
||||
const response = await fetch(url);
|
||||
const body: any = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||
return {
|
||||
client,
|
||||
@@ -160,6 +203,29 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
createClient: geminiClient,
|
||||
plainProtocol: geminiProtocol,
|
||||
toolProtocol: geminiProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
||||
url.searchParams.set("key", client.apiKey);
|
||||
url.searchParams.set("pageSize", "1000");
|
||||
const page = await fetchJson(url);
|
||||
return Array.isArray(page?.models)
|
||||
? page.models
|
||||
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
||||
.map((model: any) => model?.name)
|
||||
.filter((id: unknown): id is string => typeof id === "string")
|
||||
.map(stripModelResourcePrefix)
|
||||
.filter(isLikelyGeminiChatModel)
|
||||
: [];
|
||||
},
|
||||
sortModels: preferGeminiModels,
|
||||
},
|
||||
},
|
||||
"hermes-agent": {
|
||||
createClient: hermesAgentClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
@@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] {
|
||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||
const spec = backendSpecs[provider].modelCatalog;
|
||||
if (!spec) return [];
|
||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
return spec.sortModels ? spec.sortModels(models) : models;
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
@@ -13,6 +14,7 @@ const prismaToApiProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
hermes_agent: "hermes-agent",
|
||||
"hermes-agent": "hermes-agent",
|
||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export function openaiClient() {
|
||||
@@ -13,6 +13,14 @@ export function xaiClient() {
|
||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||
}
|
||||
|
||||
export function geminiClient() {
|
||||
if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set");
|
||||
return {
|
||||
apiKey: env.GEMINI_API_KEY,
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
};
|
||||
}
|
||||
|
||||
export function isHermesAgentConfigured() {
|
||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
if (shouldPersist && chatId && call) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.message.create({
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant" as any,
|
||||
content: text,
|
||||
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||
},
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
|
||||
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -33,6 +33,7 @@ export type ChatMessage = {
|
||||
export type MultiplexRequest = {
|
||||
chatId?: string;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
|
||||
+296
-43
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -16,8 +17,9 @@ import { exaClient } from "./search/exa.js";
|
||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||
import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||
|
||||
type IncomingChatMessage = {
|
||||
@@ -88,13 +90,13 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||
}
|
||||
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
@@ -109,14 +111,21 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
||||
if (!toInsert.length) return;
|
||||
|
||||
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
|
||||
await prisma.message.createMany({
|
||||
data: toInsert.map((m) => ({
|
||||
chatId,
|
||||
role: m.role as any,
|
||||
content: m.content,
|
||||
name: m.name,
|
||||
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
|
||||
})),
|
||||
data: toInsert.map((m, index) => {
|
||||
const metadata = {
|
||||
...(m.attachments?.length ? { attachments: m.attachments } : {}),
|
||||
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
|
||||
};
|
||||
return {
|
||||
chatId,
|
||||
role: m.role as any,
|
||||
content: m.content,
|
||||
name: m.name,
|
||||
metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +178,7 @@ const CompletionStreamBody = z
|
||||
.object({
|
||||
chatId: z.string().optional(),
|
||||
persist: z.boolean().optional(),
|
||||
clientRequestId: z.string().trim().min(1).max(128).optional(),
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
@@ -186,6 +196,13 @@ const CompletionStreamBody = z
|
||||
path: ["chatId"],
|
||||
});
|
||||
}
|
||||
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "clientRequestId requires a persisted stream with chatId",
|
||||
path: ["clientRequestId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||
@@ -302,6 +319,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
||||
}
|
||||
|
||||
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
|
||||
if (messageContent !== undefined) {
|
||||
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
|
||||
return `Fork of '${snippet}'`;
|
||||
}
|
||||
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
|
||||
}
|
||||
|
||||
export function copyForkMessageMetadata(metadata: unknown) {
|
||||
if (metadata === null || metadata === undefined) return undefined;
|
||||
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
||||
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
|
||||
return Object.keys(copied).length ? copied : undefined;
|
||||
}
|
||||
|
||||
async function generateChatTitle(content: string) {
|
||||
const systemPrompt =
|
||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||
@@ -399,6 +431,8 @@ function buildSseHeaders(originHeader: string | undefined) {
|
||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeChatStreamRequestIds = new Map<string, string>();
|
||||
const chatDeletionRoots = new Set<string>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
@@ -411,6 +445,8 @@ const starredProjectItemsSelect = {
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
titleGenerationPending: true,
|
||||
parentChatId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
@@ -491,6 +527,29 @@ async function getSearchSummary(searchId: string) {
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function listRecentChatsWithRoots() {
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
const includedIds = new Set(chats.map((chat) => chat.id));
|
||||
const missingRootIds = [
|
||||
...new Set(
|
||||
chats
|
||||
.map((chat) => chat.parentChatId)
|
||||
.filter((id): id is string => id !== null && !includedIds.has(id))
|
||||
),
|
||||
];
|
||||
if (!missingRootIds.length) return chats;
|
||||
|
||||
const roots = await prisma.chat.findMany({
|
||||
where: { id: { in: missingRootIds } },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return [...chats, ...roots].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
@@ -529,11 +588,7 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
listRecentChatsWithRoots(),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
@@ -554,6 +609,7 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||
}
|
||||
|
||||
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return reply;
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
@@ -588,10 +644,24 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
||||
return { event: ev.type, data: ev };
|
||||
}
|
||||
|
||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
const stream = new ActiveSseStream();
|
||||
activeChatStreams.set(chatId, stream);
|
||||
if (clientRequestId) {
|
||||
activeChatStreamRequestIds.set(chatId, clientRequestId);
|
||||
} else {
|
||||
activeChatStreamRequestIds.delete(chatId);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||
if (activeChatStreams.get(chatId) !== stream) return;
|
||||
activeChatStreams.delete(chatId);
|
||||
activeChatStreamRequestIds.delete(chatId);
|
||||
}
|
||||
|
||||
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
|
||||
void (async () => {
|
||||
let sawTerminalEvent = false;
|
||||
try {
|
||||
@@ -611,11 +681,46 @@ function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionSt
|
||||
} catch (err) {
|
||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
} finally {
|
||||
activeChatStreams.delete(chatId);
|
||||
clearActiveChatStream(chatId, stream);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return stream;
|
||||
function getMetadataClientRequestId(metadata: unknown) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||
return typeof clientRequestId === "string" ? clientRequestId : null;
|
||||
}
|
||||
|
||||
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
|
||||
const assistantMessages = await prisma.message.findMany({
|
||||
where: { chatId, role: "assistant" as any },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { content: true, metadata: true },
|
||||
});
|
||||
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
|
||||
}
|
||||
|
||||
function completeChatSubmissionStream(
|
||||
stream: ActiveSseStream,
|
||||
chatId: string,
|
||||
body: z.infer<typeof CompletionStreamBody>,
|
||||
assistantText: string
|
||||
) {
|
||||
stream.emit("meta", {
|
||||
type: "meta",
|
||||
chatId,
|
||||
callId: null,
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
});
|
||||
stream.complete({
|
||||
event: "done",
|
||||
data: {
|
||||
type: "done",
|
||||
text: assistantText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||
@@ -816,11 +921,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
const chats = await listRecentChatsWithRoots();
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
|
||||
@@ -879,6 +980,80 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/fork", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const parsed = Body.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const source = await tx.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
parentChatId: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
userId: true,
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!source) return { status: "chat-not-found" as const };
|
||||
|
||||
let messages = source.messages;
|
||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||
if (parsed.data.messageId) {
|
||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||
if (selectedIndex < 0) return { status: "message-not-found" as const };
|
||||
selectedMessage = messages[selectedIndex];
|
||||
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
|
||||
messages = messages.slice(0, selectedIndex + 1);
|
||||
}
|
||||
|
||||
const forkMessageIdPrefix = `fork-${randomUUID()}`;
|
||||
const chat = await tx.chat.create({
|
||||
data: {
|
||||
title: buildForkTitle(source.title, selectedMessage?.content),
|
||||
titleGenerationPending: true,
|
||||
parentChatId: source.parentChatId ?? source.id,
|
||||
initiatedProvider: source.initiatedProvider,
|
||||
initiatedModel: source.initiatedModel,
|
||||
lastUsedProvider: source.lastUsedProvider,
|
||||
lastUsedModel: source.lastUsedModel,
|
||||
additionalSystemPrompt: source.additionalSystemPrompt,
|
||||
enabledTools: (source.enabledTools ?? undefined) as any,
|
||||
userId: source.userId,
|
||||
messages: messages.length
|
||||
? {
|
||||
create: messages.map((message, index) => ({
|
||||
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
|
||||
createdAt: message.createdAt,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
name: message.name,
|
||||
metadata: copyForkMessageMetadata(message.metadata) as any,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { status: "created" as const, chat };
|
||||
});
|
||||
|
||||
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
|
||||
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
|
||||
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
|
||||
return { chat: serializeChatLike(result.chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
@@ -891,7 +1066,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.title !== undefined) {
|
||||
data.title = body.title;
|
||||
data.titleGenerationPending = false;
|
||||
}
|
||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||
|
||||
@@ -932,15 +1110,30 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
let suggestedRaw = "";
|
||||
try {
|
||||
suggestedRaw = await generateChatTitle(body.content);
|
||||
} catch (err) {
|
||||
req.log.warn(
|
||||
{
|
||||
chatId: body.chatId,
|
||||
err: getErrorMessage(err),
|
||||
},
|
||||
"chat title generation failed; using fallback"
|
||||
);
|
||||
}
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
data: { title },
|
||||
where: {
|
||||
id: body.chatId,
|
||||
title: existing.title,
|
||||
titleGenerationPending: existing.titleGenerationPending,
|
||||
},
|
||||
data: { title, titleGenerationPending: false },
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
@@ -956,14 +1149,46 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
req.log.info({ chatId }, "delete chat requested");
|
||||
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
const target = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { parentChatId: true },
|
||||
});
|
||||
if (!target) {
|
||||
req.log.warn({ chatId }, "delete chat target not found");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
const familyRootId = target.parentChatId ?? chatId;
|
||||
if (chatDeletionRoots.has(familyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
chatDeletionRoots.add(familyRootId);
|
||||
try {
|
||||
const familyIds = target.parentChatId
|
||||
? [chatId]
|
||||
: (
|
||||
await prisma.chat.findMany({
|
||||
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((chat) => chat.id);
|
||||
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
||||
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
||||
return app.httpErrors.conflict("chat or fork has an active stream");
|
||||
}
|
||||
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
req.log.warn({ chatId }, "delete chat target no longer exists");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
} finally {
|
||||
chatDeletionRoots.delete(familyRootId);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/searches", async (req) => {
|
||||
@@ -1253,7 +1478,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
@@ -1347,23 +1572,51 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// ensure chat exists if provided
|
||||
// Ensure the chat exists and identify its family before reserving a stream.
|
||||
let chatFamilyRootId: string | null = null;
|
||||
if (body.chatId) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
||||
const exists = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: { id: true, parentChatId: true },
|
||||
});
|
||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.persist !== false && body.chatId) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
||||
}
|
||||
|
||||
if (body.persist !== false && body.chatId) {
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
const activeStream = activeChatStreams.get(body.chatId);
|
||||
if (activeStream) {
|
||||
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
||||
return streamActiveRun(req, reply, activeStream);
|
||||
}
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
|
||||
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||
try {
|
||||
if (body.clientRequestId) {
|
||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||
if (completedSubmission) {
|
||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||
const configuredBody = await applyStoredChatSettings(body);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
} catch (err) {
|
||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
||||
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
||||
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
||||
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
||||
const MAX_RATE_LIMIT_RETRIES = 3;
|
||||
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
||||
|
||||
type RateLimitPolicy = {
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
};
|
||||
|
||||
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||
let lastRequestAtMs = 0;
|
||||
let nextRequestAtMs = 0;
|
||||
let quotaUnavailableUntilMs = 0;
|
||||
let requestQueue = Promise.resolve();
|
||||
|
||||
export type BraveSearchOptions = {
|
||||
numResults: number;
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
};
|
||||
|
||||
export type BraveSearchResult = {
|
||||
title: string | null;
|
||||
url: string | null;
|
||||
publishedDate: string | null;
|
||||
author: string | null;
|
||||
summary: string | null;
|
||||
text: string | null;
|
||||
highlights: string[];
|
||||
};
|
||||
|
||||
export type BraveSearchResponse = {
|
||||
query: string;
|
||||
requestId: string | null;
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
|
||||
function clipText(input: string, maxCharacters: number) {
|
||||
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
||||
}
|
||||
|
||||
function compactWhitespace(input: string) {
|
||||
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function requireBraveSearchApiKey() {
|
||||
if (!env.BRAVE_SEARCH_API_KEY) {
|
||||
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
||||
}
|
||||
return env.BRAVE_SEARCH_API_KEY;
|
||||
}
|
||||
|
||||
function sleep(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function addIntervalSafety(intervalMs: number) {
|
||||
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
||||
}
|
||||
|
||||
function parseCommaSeparatedNumbers(value: string | null) {
|
||||
if (!value) return [];
|
||||
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
||||
}
|
||||
|
||||
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
||||
if (!value) return [];
|
||||
return value.split(",").flatMap((part) => {
|
||||
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
||||
if (!match) return [];
|
||||
const limit = Number(match[1]);
|
||||
const windowSeconds = Number(match[2]);
|
||||
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
||||
if (!policies.length) return null;
|
||||
let burstIndex = 0;
|
||||
for (let index = 1; index < policies.length; index += 1) {
|
||||
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
||||
}
|
||||
return burstIndex;
|
||||
}
|
||||
|
||||
function updateRateLimitState(headers: Headers) {
|
||||
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
||||
const burstIndex = getBurstPolicyIndex(policies);
|
||||
if (burstIndex === null) return;
|
||||
|
||||
const burstPolicy = policies[burstIndex]!;
|
||||
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
||||
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
||||
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
||||
}
|
||||
requestIntervalMs = learnedIntervalMs;
|
||||
|
||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||
for (let index = 0; index < policies.length; index += 1) {
|
||||
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
||||
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
||||
if (index === burstIndex) {
|
||||
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
||||
} else {
|
||||
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertLongTermQuotaAvailable() {
|
||||
if (quotaUnavailableUntilMs <= Date.now()) {
|
||||
quotaUnavailableUntilMs = 0;
|
||||
return;
|
||||
}
|
||||
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
||||
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
||||
}
|
||||
|
||||
async function waitForRateLimitSlot() {
|
||||
const reservation = requestQueue.then(async () => {
|
||||
while (true) {
|
||||
assertLongTermQuotaAvailable();
|
||||
const waitMs = nextRequestAtMs - Date.now();
|
||||
if (waitMs <= 0) break;
|
||||
await sleep(waitMs);
|
||||
}
|
||||
lastRequestAtMs = Date.now();
|
||||
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
||||
});
|
||||
requestQueue = reservation.catch(() => undefined);
|
||||
await reservation;
|
||||
}
|
||||
|
||||
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
||||
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
||||
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
||||
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
||||
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
||||
}
|
||||
|
||||
async function fetchBrave(url: URL) {
|
||||
const apiKey = requireBraveSearchApiKey();
|
||||
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
||||
await waitForRateLimitSlot();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...buildBrowserLikeRequestHeaders("application/json"),
|
||||
"X-Subscription-Token": apiKey,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
updateRateLimitState(response.headers);
|
||||
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
||||
|
||||
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
||||
await response.arrayBuffer();
|
||||
if (retryDelayMs === null) {
|
||||
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
||||
}
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
|
||||
throw new Error("Brave Search API request failed after rate-limit retries.");
|
||||
}
|
||||
|
||||
function normalizeDomain(input: string) {
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||
return parsed.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomains(input: string[] | undefined) {
|
||||
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
||||
}
|
||||
|
||||
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
||||
if (!urlRaw) return false;
|
||||
try {
|
||||
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
||||
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
||||
const includeDomains = normalizeDomains(options.includeDomains);
|
||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||
return results.filter((result) => {
|
||||
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
||||
const includeDomains = normalizeDomains(options.includeDomains);
|
||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||
const includeClause =
|
||||
includeDomains.length === 0
|
||||
? ""
|
||||
: includeDomains.length === 1
|
||||
? `site:${includeDomains[0]}`
|
||||
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
||||
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
||||
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
||||
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
||||
url.searchParams.set("q", buildBraveQuery(query, options));
|
||||
url.searchParams.set("count", String(options.numResults));
|
||||
url.searchParams.set("safesearch", "moderate");
|
||||
url.searchParams.set("result_filter", "web");
|
||||
url.searchParams.set("text_decorations", "false");
|
||||
url.searchParams.set("extra_snippets", "true");
|
||||
return url;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = compactWhitespace(value);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
||||
}
|
||||
|
||||
function mapWebResult(result: any): BraveSearchResult {
|
||||
const description = stringOrNull(result?.description);
|
||||
const extraSnippets = stringArray(result?.extra_snippets);
|
||||
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
||||
const combinedText = snippets.join("\n\n");
|
||||
|
||||
return {
|
||||
title: stringOrNull(result?.title),
|
||||
url: stringOrNull(result?.url),
|
||||
publishedDate: stringOrNull(result?.page_age),
|
||||
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
||||
summary: description ? clipText(description, 1_400) : null,
|
||||
text: combinedText ? clipText(combinedText, 700) : null,
|
||||
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
||||
const url = buildSearchUrl(query, options);
|
||||
const response = await fetchBrave(url);
|
||||
|
||||
if (!response.ok) {
|
||||
await response.arrayBuffer();
|
||||
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
await response.arrayBuffer();
|
||||
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
||||
return {
|
||||
query,
|
||||
requestId: response.headers.get("x-request-id"),
|
||||
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetBraveRateLimitStateForTests() {
|
||||
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||
lastRequestAtMs = 0;
|
||||
nextRequestAtMs = 0;
|
||||
quotaUnavailableUntilMs = 0;
|
||||
requestQueue = Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { env } from "../src/env.js";
|
||||
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
|
||||
|
||||
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: " Brave result ",
|
||||
url: "https://docs.example.com/article",
|
||||
description: "Main\n snippet",
|
||||
extra_snippets: ["Extra snippet one", "Extra snippet two"],
|
||||
page_age: "2026-07-18T12:00:00Z",
|
||||
profile: { name: "Example Docs" },
|
||||
},
|
||||
{
|
||||
title: "Excluded result",
|
||||
url: "https://blocked.example.com/article",
|
||||
description: "Should be filtered",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-request-id": "brave-request-1",
|
||||
},
|
||||
}
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await searchBrave("latest docs", {
|
||||
numResults: 5,
|
||||
includeDomains: ["https://example.com/path"],
|
||||
excludeDomains: ["blocked.example.com"],
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
const requestUrl = new URL(String(fetchCalls[0]?.input));
|
||||
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
|
||||
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
|
||||
assert.equal(requestUrl.searchParams.get("count"), "5");
|
||||
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
|
||||
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
|
||||
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
|
||||
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
|
||||
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
|
||||
|
||||
assert.deepEqual(response, {
|
||||
query: "latest docs",
|
||||
requestId: "brave-request-1",
|
||||
results: [
|
||||
{
|
||||
title: "Brave result",
|
||||
url: "https://docs.example.com/article",
|
||||
publishedDate: "2026-07-18T12:00:00Z",
|
||||
author: "Example Docs",
|
||||
summary: "Main snippet",
|
||||
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
|
||||
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave rejects requests without an API key", async () => {
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = undefined;
|
||||
try {
|
||||
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
|
||||
} finally {
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave reports non-JSON responses", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("upstream error", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => searchBrave("test", { numResults: 1 }),
|
||||
/Brave Search API returned text\/plain/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const requestStartedAt: number[] = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
requestStartedAt.push(Date.now());
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "1, 1999",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
searchBrave("burst one", { numResults: 1 }),
|
||||
searchBrave("burst two", { numResults: 1 }),
|
||||
searchBrave("burst three", { numResults: 1 }),
|
||||
]);
|
||||
|
||||
assert.equal(requestStartedAt.length, 3);
|
||||
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
|
||||
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const requestStartedAt: number[] = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
requestStartedAt.push(Date.now());
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
|
||||
"x-ratelimit-remaining": "49, 0",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await searchBrave("learn upgraded policy", { numResults: 1 });
|
||||
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
|
||||
|
||||
assert.equal(requestStartedAt.length, 9);
|
||||
const burstStartedAt = requestStartedAt.slice(1);
|
||||
for (let index = 1; index < burstStartedAt.length; index += 1) {
|
||||
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
|
||||
}
|
||||
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave retries 429 responses after the burst window resets", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
let fetchCount = 0;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
const rateLimitHeaders = {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
};
|
||||
if (fetchCount === 1) {
|
||||
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
|
||||
status: 429,
|
||||
headers: rateLimitHeaders,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await searchBrave("retry burst", { numResults: 1 });
|
||||
assert.equal(fetchCount, 2);
|
||||
assert.ok(Date.now() - startedAt >= 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave does not wait for exhausted long-term quotas", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "0, 0",
|
||||
"x-ratelimit-reset": "1, 100000",
|
||||
},
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
() => searchBrave("quota exhausted", { numResults: 1 }),
|
||||
/rate limit quota is exhausted beyond the retry window/
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
let fetchCount = 0;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "0, 0",
|
||||
"x-ratelimit-reset": "1, 100000",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await searchBrave("last allowed query", { numResults: 1 });
|
||||
await assert.rejects(
|
||||
() => searchBrave("over quota query", { numResults: 1 }),
|
||||
/long-term quota is exhausted/
|
||||
);
|
||||
assert.equal(fetchCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import sensible from "@fastify/sensible";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
||||
cwd: serverRoot,
|
||||
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
|
||||
import("../src/routes.js"),
|
||||
import("../src/db.js"),
|
||||
]);
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("forks copy bounded history and keep every child grouped under the root", async () => {
|
||||
const timestamp = new Date("2026-08-16T12:00:00.000Z");
|
||||
const source = await prisma.chat.create({
|
||||
data: {
|
||||
title: "Planning session",
|
||||
initiatedProvider: "openai",
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: "anthropic",
|
||||
lastUsedModel: "claude-sonnet-4-20250514",
|
||||
additionalSystemPrompt: "Keep answers concise.",
|
||||
enabledTools: ["web_search"],
|
||||
messages: {
|
||||
create: [
|
||||
{
|
||||
id: "source-message-0001",
|
||||
createdAt: timestamp,
|
||||
role: "user",
|
||||
content: "Plan a trip",
|
||||
metadata: {
|
||||
clientRequestId: "source-request",
|
||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source-message-0002",
|
||||
createdAt: timestamp,
|
||||
role: "assistant",
|
||||
content: "First assistant response",
|
||||
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
|
||||
},
|
||||
{
|
||||
id: "source-message-0003",
|
||||
createdAt: timestamp,
|
||||
role: "user",
|
||||
content: "Only visible in a whole-chat fork",
|
||||
},
|
||||
],
|
||||
},
|
||||
calls: {
|
||||
create: {
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
request: { input: "Plan a trip" },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
|
||||
const forkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: source.messages[1].id },
|
||||
});
|
||||
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
|
||||
const forkSummary = forkResponse.json().chat;
|
||||
assert.equal(forkSummary.parentChatId, source.id);
|
||||
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
|
||||
assert.equal(forkSummary.titleGenerationPending, true);
|
||||
assert.equal(forkSummary.starred, false);
|
||||
|
||||
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
|
||||
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
|
||||
const fork = detailResponse.json().chat;
|
||||
assert.deepEqual(
|
||||
fork.messages.map((message: any) => [message.role, message.content]),
|
||||
[
|
||||
["user", "Plan a trip"],
|
||||
["assistant", "First assistant response"],
|
||||
]
|
||||
);
|
||||
assert.notEqual(fork.messages[0].id, source.messages[0].id);
|
||||
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
|
||||
assert.deepEqual(fork.messages[0].metadata, {
|
||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||
});
|
||||
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
|
||||
assert.equal(fork.initiatedProvider, "openai");
|
||||
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
|
||||
assert.equal(fork.lastUsedProvider, "anthropic");
|
||||
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
|
||||
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
|
||||
assert.deepEqual(fork.enabledTools, ["web_search"]);
|
||||
assert.deepEqual(fork.calls, []);
|
||||
|
||||
const childForkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${forkSummary.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
|
||||
const childFork = childForkResponse.json().chat;
|
||||
assert.equal(childFork.parentChatId, source.id);
|
||||
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
|
||||
assert.equal(childFork.titleGenerationPending, true);
|
||||
|
||||
const wholeForkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
|
||||
const wholeFork = wholeForkResponse.json().chat;
|
||||
assert.equal(wholeFork.parentChatId, source.id);
|
||||
assert.equal(wholeFork.title, "Fork of Planning session");
|
||||
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
|
||||
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
|
||||
assert.deepEqual(
|
||||
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
|
||||
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
|
||||
);
|
||||
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
|
||||
|
||||
const suggestedTitleResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/chats/title/suggest",
|
||||
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
|
||||
});
|
||||
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
|
||||
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
|
||||
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
|
||||
|
||||
const repeatedTitleResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/chats/title/suggest",
|
||||
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
|
||||
});
|
||||
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
|
||||
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
|
||||
|
||||
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
|
||||
where: { id: source.id },
|
||||
include: { messages: true, calls: true },
|
||||
});
|
||||
assert.equal(sourceAfterForks.messages.length, 3);
|
||||
assert.equal(sourceAfterForks.calls.length, 1);
|
||||
|
||||
const manualTitleResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/v1/chats/${forkSummary.id}`,
|
||||
payload: { title: "Manual branch title" },
|
||||
});
|
||||
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
|
||||
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
|
||||
|
||||
const activeForkStream = registerActiveChatStream(wholeFork.id);
|
||||
try {
|
||||
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
|
||||
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
|
||||
|
||||
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
|
||||
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
|
||||
} finally {
|
||||
clearActiveChatStream(wholeFork.id, activeForkStream);
|
||||
}
|
||||
|
||||
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
|
||||
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
|
||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
|
||||
|
||||
const unrelated = await prisma.chat.create({
|
||||
data: {
|
||||
messages: { create: { role: "assistant", content: "Unrelated response" } },
|
||||
},
|
||||
include: { messages: true },
|
||||
});
|
||||
const countBeforeInvalidForks = await prisma.chat.count();
|
||||
|
||||
const wrongChatResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: unrelated.messages[0].id },
|
||||
});
|
||||
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
|
||||
assert.equal(wrongChatResponse.json().message, "message not found in chat");
|
||||
|
||||
const nonAssistantResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: source.messages[0].id },
|
||||
});
|
||||
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
|
||||
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
|
||||
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
|
||||
|
||||
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
|
||||
const concurrentDeleteResponses = await Promise.all([
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||
]);
|
||||
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
|
||||
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||
|
||||
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
|
||||
const concurrentChildResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${concurrentRoot.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
|
||||
const concurrentChild = concurrentChildResponse.json().chat;
|
||||
const concurrentFamilyDeleteResponses = await Promise.all([
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
|
||||
]);
|
||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
|
||||
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
|
||||
|
||||
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
|
||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
|
||||
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
|
||||
});
|
||||
@@ -27,6 +27,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), {
|
||||
provider: "gemini",
|
||||
protocol: "gemini",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||
provider: "hermes-agent",
|
||||
protocol: "chat-completions",
|
||||
|
||||
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||
assert.equal(toPrismaProvider("gemini"), "gemini");
|
||||
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||
initiatedProvider: "hermes-agent",
|
||||
lastUsedProvider: "xai",
|
||||
lastUsedProvider: "gemini",
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
+45
-22
@@ -42,12 +42,13 @@ type ToolLogMetadata = {
|
||||
resultPreview?: string | null;
|
||||
};
|
||||
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
"hermes-agent": ["hermes-agent"],
|
||||
};
|
||||
|
||||
@@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
anthropic: { models: [], loadedAt: null, error: null },
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
gemini: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
|
||||
function escapeTags(value: string) {
|
||||
@@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "gemini") return "Gemini";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
@@ -124,21 +127,27 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
||||
}
|
||||
|
||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
const chatsById = new Map<string, ChatSummary>();
|
||||
for (const item of items) {
|
||||
if (item.type === "chat") chatsById.set(item.id, item);
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.type === "chat") {
|
||||
const chat = item;
|
||||
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
|
||||
return {
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: starOwner.starred,
|
||||
starredAt: starOwner.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,6 +275,7 @@ async function main() {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
gemini: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||
@@ -975,11 +985,11 @@ async function main() {
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
async function maybeSuggestTitle(chatId: string, content: string) {
|
||||
const chatSummary = chats.find((chat) => chat.id === chatId);
|
||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
|
||||
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
||||
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
||||
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
||||
|
||||
const chatId = chat.id;
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
@@ -989,6 +999,8 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1045,6 +1057,8 @@ async function main() {
|
||||
selectedChat = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
parentChatId: chat.parentChatId,
|
||||
titleGenerationPending: chat.titleGenerationPending,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
@@ -1064,13 +1078,13 @@ async function main() {
|
||||
throw new Error("Unable to initialize chat");
|
||||
}
|
||||
|
||||
void maybeSuggestTitle(chatId, content);
|
||||
|
||||
let baseChat = selectedChat;
|
||||
if (!baseChat || baseChat.id !== chatId) {
|
||||
baseChat = await api.getChat(chatId);
|
||||
}
|
||||
|
||||
void maybeSuggestTitle(baseChat, content);
|
||||
|
||||
const requestMessages: CompletionRequestMessage[] = [
|
||||
...baseChat.messages
|
||||
.filter((message) => !isToolCallLogMessage(message))
|
||||
@@ -1388,6 +1402,8 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
@@ -1401,12 +1417,16 @@ async function main() {
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
|
||||
const selectedParentChatId = selectedChat?.id === selectedItem.id
|
||||
? selectedChat.parentChatId
|
||||
: selectedSummary?.parentChatId;
|
||||
const rootChatId = selectedParentChatId ?? selectedItem.id;
|
||||
const rootSummary = chats.find((chat) => chat.id === rootChatId);
|
||||
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
@@ -1417,6 +1437,8 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1427,7 +1449,8 @@ async function main() {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
||||
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -13,6 +13,8 @@ export type ModelCatalogResponse = {
|
||||
export type ChatSummary = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
@@ -68,6 +70,8 @@ export type ToolCallEvent = {
|
||||
export type ChatDetail = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+16
-2
@@ -3,10 +3,24 @@ self.addEventListener("install", () => {
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim();
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
await Promise.all(
|
||||
windows.map(async (client) => {
|
||||
try {
|
||||
await client.navigate(client.url);
|
||||
} catch {
|
||||
// The client may have closed while the new worker was activating.
|
||||
}
|
||||
})
|
||||
);
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(event.request));
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
});
|
||||
|
||||
+1051
-408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MutableValueRef = {
|
||||
current: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
draftRef: MutableValueRef;
|
||||
draftRevision: number;
|
||||
error: string | null;
|
||||
isSearchMode: boolean;
|
||||
isSending: boolean;
|
||||
pendingAttachmentCount: number;
|
||||
attachmentButtonDisabled: boolean;
|
||||
onOpenAttachmentPicker: () => void;
|
||||
onPaste: (event: ClipboardEvent) => void;
|
||||
onSend: (draft: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MIRROR_SENTINEL = "\u200b";
|
||||
const HAS_NON_WHITESPACE = /\S/;
|
||||
|
||||
export function ChatComposer({
|
||||
draftRef,
|
||||
draftRevision,
|
||||
error,
|
||||
isSearchMode,
|
||||
isSending,
|
||||
pendingAttachmentCount,
|
||||
attachmentButtonDisabled,
|
||||
onOpenAttachmentPicker,
|
||||
onPaste,
|
||||
onSend,
|
||||
}: Props) {
|
||||
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
|
||||
const textareaContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement>(null);
|
||||
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
|
||||
const hasDraftRef = useRef(hasDraft);
|
||||
|
||||
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
|
||||
|
||||
const updateMirror = (value: string) => {
|
||||
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
|
||||
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
|
||||
};
|
||||
|
||||
const updateHasDraft = (value: string) => {
|
||||
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
|
||||
if (nextHasDraft !== hasDraftRef.current) {
|
||||
hasDraftRef.current = nextHasDraft;
|
||||
setHasDraft(nextHasDraft);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const value = draftRef.current;
|
||||
const textarea = getTextarea();
|
||||
if (textarea && textarea.value !== value) {
|
||||
textarea.value = value;
|
||||
}
|
||||
updateMirror(value);
|
||||
updateHasDraft(value);
|
||||
}, [draftRevision]);
|
||||
|
||||
const submit = () => {
|
||||
const textarea = getTextarea();
|
||||
const draft = textarea?.value ?? draftRef.current;
|
||||
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
|
||||
if (isSending || !canSend) return;
|
||||
|
||||
draftRef.current = "";
|
||||
if (textarea) textarea.value = "";
|
||||
updateMirror("");
|
||||
updateHasDraft("");
|
||||
void onSend(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Textarea
|
||||
id="composer-input"
|
||||
rows={1}
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
draftRef.current = value;
|
||||
updateMirror(value);
|
||||
updateHasDraft(value);
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
onPaste(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
||||
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||
disabled={isSending}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
||||
{!isSearchMode ? (
|
||||
<Button
|
||||
className="h-10 w-10 rounded-lg"
|
||||
onClick={onOpenAttachmentPicker}
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
disabled={attachmentButtonDisabled}
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="h-10 w-10 rounded-lg"
|
||||
onClick={submit}
|
||||
size="icon"
|
||||
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
|
||||
aria-label={isSearchMode ? "Search" : "Send message"}
|
||||
>
|
||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isSending: boolean;
|
||||
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
|
||||
};
|
||||
|
||||
type ToolLogMetadata = {
|
||||
@@ -395,7 +396,7 @@ function ToolCallStack({
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
@@ -467,6 +468,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
: "text-base leading-7 text-violet-50"
|
||||
)}
|
||||
onContextMenu={
|
||||
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||
? (event) => onMessageContextMenu(event, message.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||
{isPendingAssistant ? (
|
||||
@@ -478,6 +484,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
) : message.content.trim() ? (
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
className?: string;
|
||||
mode?: MarkdownMode;
|
||||
resolveCitationIndex?: (href: string) => number | undefined;
|
||||
openLinksInNewTab?: boolean;
|
||||
};
|
||||
|
||||
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
||||
@@ -28,17 +29,30 @@ markdownRenderer.table = (token) => {
|
||||
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
||||
};
|
||||
|
||||
function renderMarkdown(markdown: string) {
|
||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
function setNewTabLinkAttributes(currentNode: Element) {
|
||||
if (currentNode.tagName !== "A") return;
|
||||
currentNode.setAttribute("target", "_blank");
|
||||
currentNode.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) {
|
||||
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
|
||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||
try {
|
||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
} finally {
|
||||
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
|
||||
const html = useMemo(() => {
|
||||
const prepared =
|
||||
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
||||
return renderMarkdown(prepared);
|
||||
}, [markdown, mode, resolveCitationIndex]);
|
||||
return renderMarkdown(prepared, openLinksInNewTab);
|
||||
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
|
||||
|
||||
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,45 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
.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));
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
||||
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
min-width: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
.workspace-sidebar-backdrop,
|
||||
.workspace-sidebar-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||
|
||||
+26
-151
@@ -1,6 +1,8 @@
|
||||
export type ChatSummary = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
@@ -58,6 +60,8 @@ export type ToolCallEvent = {
|
||||
export type ChatDetail = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
@@ -149,7 +153,7 @@ export type CompletionRequestMessage = {
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -291,6 +295,14 @@ export async function getChat(chatId: string) {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function forkChat(chatId: string, messageId?: string) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(messageId ? { messageId } : {}),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatTitle(chatId: string, title: string) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||
method: "PATCH",
|
||||
@@ -450,6 +462,7 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
let sawTerminalEvent = false;
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!dataLines.length) {
|
||||
@@ -466,6 +479,9 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
||||
}
|
||||
|
||||
dispatch(eventName, payload);
|
||||
if (eventName === "done" || eventName === "error") {
|
||||
sawTerminalEvent = true;
|
||||
}
|
||||
|
||||
dataLines = [];
|
||||
eventName = "message";
|
||||
@@ -505,6 +521,10 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
||||
}
|
||||
}
|
||||
flushEvent();
|
||||
|
||||
if (!sawTerminalEvent) {
|
||||
throw new Error("Stream disconnected before completion");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSearchStream(
|
||||
@@ -528,87 +548,14 @@ export async function runSearchStream(
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = `${response.status} ${response.statusText}`;
|
||||
let message = fallback;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
// keep fallback message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response stream");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!dataLines.length) {
|
||||
eventName = "message";
|
||||
return;
|
||||
}
|
||||
|
||||
const dataText = dataLines.join("\n");
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = JSON.parse(dataText);
|
||||
} catch {
|
||||
payload = { message: dataText };
|
||||
}
|
||||
|
||||
await readSseStream(response, (eventName, payload) => {
|
||||
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||
else if (eventName === "done") handlers.onDone?.(payload);
|
||||
else if (eventName === "error") handlers.onError?.(payload);
|
||||
|
||||
dataLines = [];
|
||||
eventName = "message";
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
|
||||
if (!line) {
|
||||
flushEvent();
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length) {
|
||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
flushEvent();
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||
@@ -654,6 +601,7 @@ export async function runCompletionStream(
|
||||
body: {
|
||||
chatId?: string | null;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
@@ -679,86 +627,13 @@ export async function runCompletionStream(
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = `${response.status} ${response.statusText}`;
|
||||
let message = fallback;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
// keep fallback message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response stream");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!dataLines.length) {
|
||||
eventName = "message";
|
||||
return;
|
||||
}
|
||||
|
||||
const dataText = dataLines.join("\n");
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = JSON.parse(dataText);
|
||||
} catch {
|
||||
payload = { message: dataText };
|
||||
}
|
||||
|
||||
await readSseStream(response, (eventName, payload) => {
|
||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||
else if (eventName === "done") handlers.onDone?.(payload);
|
||||
else if (eventName === "error") handlers.onError?.(payload);
|
||||
|
||||
dataLines = [];
|
||||
eventName = "message";
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
|
||||
if (!line) {
|
||||
flushEvent();
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length) {
|
||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
flushEvent();
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export type ForkGroupingItem = {
|
||||
kind: "chat" | "search";
|
||||
id: string;
|
||||
parentChatId: string | null;
|
||||
};
|
||||
|
||||
type ChatTitleState = {
|
||||
title: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
};
|
||||
|
||||
type ForkPresentationItem = ForkGroupingItem & {
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export function groupForkedChatItems<T extends ForkGroupingItem>(items: T[]): T[][] {
|
||||
const groups = new Map<string, { firstIndex: number; items: Array<{ item: T; index: number }> }>();
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`;
|
||||
const group = groups.get(groupKey);
|
||||
if (group) {
|
||||
group.items.push({ item, index });
|
||||
return;
|
||||
}
|
||||
groups.set(groupKey, { firstIndex: index, items: [{ item, index }] });
|
||||
});
|
||||
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => a.firstIndex - b.firstIndex)
|
||||
.map((group) =>
|
||||
group.items
|
||||
.sort((a, b) => {
|
||||
const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null;
|
||||
const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null;
|
||||
if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterSidebarItemsWithForkGroups<T extends ForkGroupingItem>(items: T[], matches: (item: T) => boolean): T[] {
|
||||
return groupForkedChatItems(items)
|
||||
.flatMap((group) => {
|
||||
const matchingItems = group.filter(matches);
|
||||
if (!matchingItems.length) return [];
|
||||
const root = group.find((item) => item.kind === "chat" && item.parentChatId === null);
|
||||
if (!root || matchingItems.includes(root)) return group;
|
||||
return group.filter((item) => item === root || matchingItems.includes(item));
|
||||
});
|
||||
}
|
||||
|
||||
export function getForkGroupPresentation<T extends ForkPresentationItem>(groupItems: T[], allItems: T[]) {
|
||||
const firstItem = groupItems[0];
|
||||
if (!firstItem) return null;
|
||||
|
||||
const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null;
|
||||
const familyItems = rootId
|
||||
? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId))
|
||||
: groupItems;
|
||||
const updatedAt = familyItems.reduce(
|
||||
(newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest),
|
||||
firstItem.updatedAt
|
||||
);
|
||||
const starOwner = rootId
|
||||
? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null)
|
||||
: firstItem;
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
starred: starOwner?.starred ?? false,
|
||||
starredAt: starOwner?.starredAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) {
|
||||
if (!chat || isRequestInFlight) return false;
|
||||
return chat.titleGenerationPending || !chat.title?.trim();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Provider } from "./api";
|
||||
|
||||
type PersistedChatModel = {
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
};
|
||||
|
||||
export type ChatModelSelection = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null {
|
||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||
return {
|
||||
provider: chat.lastUsedProvider,
|
||||
model: chat.lastUsedModel.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) {
|
||||
if (!chatId || !selection) return null;
|
||||
return JSON.stringify([chatId, selection.provider, selection.model]);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
|
||||
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
||||
|
||||
type ResolveSidebarSelectionOptions = {
|
||||
initialSelection?: SidebarSelection;
|
||||
selectFallback?: boolean;
|
||||
};
|
||||
|
||||
export function resolveSidebarSelectionAfterRefresh(
|
||||
current: SidebarSelection | null,
|
||||
workspaceItems: WorkspaceSelectionItem[],
|
||||
{ initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {}
|
||||
): SidebarSelection | null {
|
||||
const hasItem = (candidate: SidebarSelection | null | undefined) => {
|
||||
if (!candidate) return false;
|
||||
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
||||
};
|
||||
|
||||
if (hasItem(current)) {
|
||||
return current;
|
||||
}
|
||||
if (hasItem(initialSelection)) {
|
||||
return initialSelection ?? null;
|
||||
}
|
||||
if (!selectFallback) {
|
||||
return null;
|
||||
}
|
||||
const first = workspaceItems[0];
|
||||
return first ? { kind: first.type, id: first.id } : null;
|
||||
}
|
||||
+6
-3
@@ -2,8 +2,11 @@ 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);
|
||||
});
|
||||
void navigator.serviceWorker
|
||||
.register("/sw.js", { updateViaCache: "none" })
|
||||
.then((registration) => registration.update())
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
filterSidebarItemsWithForkGroups,
|
||||
getForkGroupPresentation,
|
||||
groupForkedChatItems,
|
||||
shouldRequestChatTitle,
|
||||
} from "../src/lib/chat-forking.ts";
|
||||
|
||||
const root = { kind: "chat", id: "root", parentChatId: null };
|
||||
const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" };
|
||||
const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" };
|
||||
const search = { kind: "search", id: "search", parentChatId: null };
|
||||
|
||||
test("fork groups are positioned by their newest member and always render root first", () => {
|
||||
assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [
|
||||
[root, nestedFork, firstFork],
|
||||
[search],
|
||||
]);
|
||||
});
|
||||
|
||||
test("sidebar filtering keeps a matching fork with its root", () => {
|
||||
assert.deepEqual(
|
||||
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"),
|
||||
[root, nestedFork]
|
||||
);
|
||||
});
|
||||
|
||||
test("sidebar filtering keeps all children when their root matches", () => {
|
||||
assert.deepEqual(
|
||||
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"),
|
||||
[root, firstFork, nestedFork]
|
||||
);
|
||||
});
|
||||
|
||||
test("a filtered family keeps its full-family date and root-owned star state", () => {
|
||||
const fullFamily = [
|
||||
{ ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" },
|
||||
{ ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null },
|
||||
{ ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null },
|
||||
];
|
||||
const filteredFamily = [fullFamily[0], fullFamily[1]];
|
||||
|
||||
assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), {
|
||||
updatedAt: "2026-08-16T00:00:00.000Z",
|
||||
starred: true,
|
||||
starredAt: "2026-08-15T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("a fork placeholder title is replaced on its first submitted prompt", () => {
|
||||
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true);
|
||||
assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false);
|
||||
});
|
||||
|
||||
test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => {
|
||||
assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true);
|
||||
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
getChatModelSelection,
|
||||
getChatModelSelectionSyncKey,
|
||||
} from "../src/lib/chat-model-selection.ts";
|
||||
|
||||
test("chat model selections are normalized from persisted metadata", () => {
|
||||
assert.deepEqual(
|
||||
getChatModelSelection({
|
||||
lastUsedProvider: "anthropic",
|
||||
lastUsedModel: " claude-sonnet-4-5 ",
|
||||
}),
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated chat updates do not change the model synchronization key", () => {
|
||||
const beforeSettingsSave = getChatModelSelection({
|
||||
lastUsedProvider: "openai",
|
||||
lastUsedModel: "gpt-4.1-mini",
|
||||
});
|
||||
const afterSettingsSave = getChatModelSelection({
|
||||
lastUsedProvider: "openai",
|
||||
lastUsedModel: "gpt-4.1-mini",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getChatModelSelectionSyncKey("chat-1", beforeSettingsSave),
|
||||
getChatModelSelectionSyncKey("chat-1", afterSettingsSave)
|
||||
);
|
||||
});
|
||||
|
||||
test("switching chats or persisted models changes the synchronization key", () => {
|
||||
const original = { provider: "openai", model: "gpt-4.1-mini" };
|
||||
const updated = { provider: "gemini", model: "gemini-3.5-flash" };
|
||||
|
||||
assert.notEqual(
|
||||
getChatModelSelectionSyncKey("chat-1", original),
|
||||
getChatModelSelectionSyncKey("chat-2", original)
|
||||
);
|
||||
assert.notEqual(
|
||||
getChatModelSelectionSyncKey("chat-1", original),
|
||||
getChatModelSelectionSyncKey("chat-1", updated)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
|
||||
|
||||
const workspaceItems = [
|
||||
{ type: "chat", id: "completed-chat" },
|
||||
{ type: "chat", id: "selected-chat" },
|
||||
{ type: "search", id: "selected-search" },
|
||||
];
|
||||
|
||||
test("a collection refresh preserves the current thread selection", () => {
|
||||
assert.deepEqual(
|
||||
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
|
||||
{ kind: "chat", id: "selected-chat" }
|
||||
);
|
||||
});
|
||||
|
||||
test("an initial route selection cannot override a current thread selection", () => {
|
||||
assert.deepEqual(
|
||||
resolveSidebarSelectionAfterRefresh(
|
||||
{ kind: "search", id: "selected-search" },
|
||||
workspaceItems,
|
||||
{ initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true }
|
||||
),
|
||||
{ kind: "search", id: "selected-search" }
|
||||
);
|
||||
});
|
||||
|
||||
test("a collection refresh preserves an intentionally empty selection", () => {
|
||||
assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null);
|
||||
});
|
||||
|
||||
test("initial load can select the URL thread or fall back to the first item", () => {
|
||||
assert.deepEqual(
|
||||
resolveSidebarSelectionAfterRefresh(null, workspaceItems, {
|
||||
initialSelection: { kind: "search", id: "selected-search" },
|
||||
selectFallback: true,
|
||||
}),
|
||||
{ kind: "search", id: "selected-search" }
|
||||
);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), {
|
||||
kind: "chat",
|
||||
id: "completed-chat",
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
{"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"}
|
||||
{"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-composer.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/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user