Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8676027db | ||
|
|
d36d2c60a3 | ||
|
|
3d7031bb40 | ||
|
|
fa9b725c77 | ||
|
|
a88987d08d | ||
|
|
e137ea1077 | ||
|
|
fad25d7f2b | ||
|
|
fb28508764 | ||
|
|
4365798f5e |
@@ -0,0 +1,218 @@
|
||||
name: TestFlight Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
runs-on: xcode
|
||||
env:
|
||||
SIGNING_KEYCHAIN: sybil_signing_temp
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate release tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag_name="${GITHUB_REF#refs/tags/}"
|
||||
if [[ ! "$tag_name" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Release tag must match release/vN.N.N; got ${tag_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="${tag_name#release/v}"
|
||||
{
|
||||
echo "TAG_NAME=${tag_name}"
|
||||
echo "RELEASE_VERSION=${release_version}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
- name: Install Ruby gems
|
||||
working-directory: ios
|
||||
run: bundle install
|
||||
|
||||
- name: Install release tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing_tools=()
|
||||
for tool in xcodegen jq; do
|
||||
if ! command -v "${tool}" >/dev/null 2>&1; then
|
||||
missing_tools+=("${tool}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${#missing_tools[@]}" -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "Missing required tools: ${missing_tools[*]}; Homebrew is not available to install them" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
brew install "${missing_tools[@]}"
|
||||
|
||||
- name: Install signing secrets
|
||||
env:
|
||||
APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
|
||||
APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
|
||||
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}"
|
||||
: "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}"
|
||||
: "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}"
|
||||
|
||||
keychain_password="$(uuidgen)"
|
||||
keychain_path="${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db"
|
||||
mkdir -p "${HOME}/Library/Keychains" "${HOME}/Library/MobileDevice/Provisioning Profiles" ios/build/secrets
|
||||
|
||||
printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > ios/build/secrets/appstore-signing.p12
|
||||
printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${HOME}/Library/MobileDevice/Provisioning Profiles/Sybil_AppStore_CI.mobileprovision"
|
||||
curl -fsSL https://www.apple.com/appleca/AppleIncRootCertificate.cer -o ios/build/secrets/AppleIncRootCertificate.cer
|
||||
curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -o ios/build/secrets/AppleWWDRCAG3.cer
|
||||
|
||||
security create-keychain -p "${keychain_password}" "${keychain_path}"
|
||||
security set-keychain-settings -lut 21600 "${keychain_path}"
|
||||
security unlock-keychain -p "${keychain_password}" "${keychain_path}"
|
||||
security list-keychains -d user -s "${keychain_path}" $(security list-keychains -d user | sed 's/[ "]//g')
|
||||
security add-trusted-cert -r trustRoot -k "${keychain_path}" ios/build/secrets/AppleIncRootCertificate.cer
|
||||
security import ios/build/secrets/AppleWWDRCAG3.cer \
|
||||
-k "${keychain_path}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/xcodebuild
|
||||
security import ios/build/secrets/appstore-signing.p12 \
|
||||
-k "${keychain_path}" \
|
||||
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/xcodebuild
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_path}"
|
||||
security find-identity -v -p codesigning "${keychain_path}"
|
||||
echo "SYBIL_SIGNING_KEYCHAIN_PATH=${keychain_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64: "true"
|
||||
FASTLANE_DONT_STORE_PASSWORD: "1"
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta
|
||||
|
||||
- name: Locate IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ipa_path="$(find ios/build/fastlane -maxdepth 1 -type f -name '*.ipa' -print | sort | tail -n 1)"
|
||||
if [[ -z "${ipa_path}" ]]; then
|
||||
echo "No IPA found under ios/build/fastlane" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "IPA_PATH=${ipa_path}"
|
||||
echo "IPA_NAME=$(basename "${ipa_path}")"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Publish Gitea release asset
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_API_URL: ${{ github.api_url }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
|
||||
api_url="${RELEASE_API_URL:-https://code.buzzert.dev/api/v1}"
|
||||
repository="${RELEASE_REPOSITORY:-buzzert/Sybil-2}"
|
||||
sha="${RELEASE_SHA:-${GITHUB_SHA:-}}"
|
||||
release_name="Sybil v${RELEASE_VERSION}"
|
||||
release_body="Automated TestFlight release for ${TAG_NAME}."
|
||||
release_payload="$(jq -nc \
|
||||
--arg tag "${TAG_NAME}" \
|
||||
--arg name "${release_name}" \
|
||||
--arg body "${release_body}" \
|
||||
--arg target "${sha}" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false} +
|
||||
(if $target == "" then {} else {target_commitish: $target} end)')"
|
||||
|
||||
response_file="$(mktemp)"
|
||||
status="$(curl -sS -o "${response_file}" -w "%{http_code}" \
|
||||
-X POST "${api_url}/repos/${repository}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${release_payload}")"
|
||||
|
||||
if [[ "${status}" == "201" ]]; then
|
||||
release_id="$(jq -r '.id' "${response_file}")"
|
||||
elif [[ "${status}" == "409" ]]; then
|
||||
release_id="$(curl -fsS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases?limit=100" |
|
||||
jq -r --arg tag "${TAG_NAME}" '.[] | select(.tag_name == $tag) | .id' |
|
||||
head -n 1)"
|
||||
else
|
||||
cat "${response_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
|
||||
echo "Could not resolve Gitea release id for ${TAG_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing_asset_id="$(curl -fsS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets" |
|
||||
jq -r --arg name "${IPA_NAME}" '.[] | select(.name == $name) | .id' |
|
||||
head -n 1)"
|
||||
|
||||
if [[ -n "${existing_asset_id}" && "${existing_asset_id}" != "null" ]]; then
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets/${existing_asset_id}"
|
||||
fi
|
||||
|
||||
asset_name="$(jq -rn --arg value "${IPA_NAME}" '$value | @uri')"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${IPA_PATH}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
|
||||
|
||||
echo "Published ${IPA_NAME} to ${release_name}"
|
||||
|
||||
- name: Clean up temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db" || true
|
||||
@@ -1,39 +0,0 @@
|
||||
name: TestFlight
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/ios/v*.*.*"
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
name: Build and upload
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Check out the release tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3.11"
|
||||
bundler-cache: true
|
||||
working-directory: ios
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
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_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
CI: "true"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
run: bundle exec fastlane ios beta
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
.env
|
||||
ios/fastlane/README.md
|
||||
ios/fastlane/report.xml
|
||||
|
||||
|
||||
Vendored
-32
@@ -1,8 +1,3 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -17,44 +12,17 @@ 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 Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
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,12 +12,10 @@ 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}
|
||||
|
||||
+14
-141
@@ -31,16 +31,14 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"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, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
|
||||
@@ -58,7 +56,7 @@ Chat upload limits:
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
@@ -81,8 +79,6 @@ Behavior notes:
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
|
||||
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
@@ -91,8 +87,6 @@ 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,
|
||||
@@ -121,19 +115,9 @@ 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 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.
|
||||
- The response currently combines up to 100 chats and up to 100 searches.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
History search (`q`) notes:
|
||||
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
|
||||
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
|
||||
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
|
||||
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
|
||||
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
|
||||
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
|
||||
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -144,7 +128,7 @@ History search (`q`) notes:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
@@ -162,32 +146,15 @@ History search (`q`) 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`.
|
||||
@@ -214,19 +181,13 @@ Behavior notes:
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
Behavior notes:
|
||||
- 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 the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- 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.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- 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 }`
|
||||
@@ -273,7 +234,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -324,7 +285,6 @@ Behavior notes:
|
||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||
@@ -333,16 +293,13 @@ 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`, `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.
|
||||
- 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`.
|
||||
- `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.
|
||||
@@ -360,16 +317,6 @@ 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`
|
||||
@@ -465,15 +412,13 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
@@ -519,15 +464,13 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
@@ -562,73 +505,3 @@ Behavior notes:
|
||||
```
|
||||
|
||||
For streaming contracts, see `docs/api/streaming-chat.md`.
|
||||
|
||||
## Sybil Agent Room
|
||||
|
||||
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
|
||||
background work, and history; these endpoints do not create ordinary Sybil chats or
|
||||
copy the transcript into the Sybil message database. All endpoints use normal Sybil
|
||||
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
|
||||
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
|
||||
|
||||
### `GET /v1/agent`
|
||||
|
||||
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
|
||||
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
|
||||
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
|
||||
configuration returns 503; an unreachable agent server or malformed upstream JSON
|
||||
returns 502.
|
||||
|
||||
### `GET /v1/agent/messages`
|
||||
|
||||
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
|
||||
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
|
||||
history. `after` catches up forward; `before` loads earlier history.
|
||||
|
||||
```json
|
||||
{
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
|
||||
"cursor": "43",
|
||||
"beforeCursor": "43",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Messages use `user`, `assistant`, and `tool` roles. Tools use
|
||||
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
|
||||
pages can update initiated tools to completed or failed. Internal worker reports,
|
||||
event prompts, and model reasoning are excluded. Cursors refer to underlying
|
||||
durable entries and can advance without visible messages. Use returned cursors;
|
||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||
messages.
|
||||
|
||||
### `POST /v1/agent/messages`
|
||||
|
||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||
Content must contain non-whitespace text and is limited to 40,000 characters;
|
||||
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
|
||||
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
|
||||
`wait: false`.
|
||||
|
||||
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
|
||||
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
|
||||
completion: watch the room for answers, including unsolicited later replies.
|
||||
Multiple requests can be accepted while the agent works. Attachments are not yet supported.
|
||||
The proxy verifies successful receipts contain a positive submission ID, a known
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
|
||||
Invalid receipts return 502; delivery remains uncertain, so retry using the original
|
||||
`requestId` to avoid creating a duplicate message.
|
||||
|
||||
### `GET /v1/agent/read-state`
|
||||
### `PUT /v1/agent/read-state`
|
||||
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
|
||||
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
|
||||
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
|
||||
a stale browser from lowering a cursor saved by another browser. Advance it only
|
||||
after showing the corresponding history; poll it to reconcile unread indicators
|
||||
across clients. Agent REST responses use `Cache-Control: no-store`.
|
||||
|
||||
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
|
||||
|
||||
+7
-104
@@ -1,29 +1,27 @@
|
||||
# Streaming Chat API Contract
|
||||
|
||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
|
||||
This document defines the server-sent events (SSE) contract for chat completions.
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/chat-completions/stream`
|
||||
- `POST /v1/chats/:chatId/stream/attach`
|
||||
- `POST /v1/quick-questions/stream`
|
||||
|
||||
Transport:
|
||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||
- Request body is JSON
|
||||
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
||||
|
||||
Authentication:
|
||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||
|
||||
## Chat Completion Request Body
|
||||
## Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"clientRequestId": "optional-client-generated-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -63,21 +61,15 @@ 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.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||
- 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`, 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" }`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||
|
||||
## Attach Endpoint
|
||||
@@ -91,32 +83,6 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
|
||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||
|
||||
## Quick Question Endpoint
|
||||
|
||||
`POST /v1/quick-questions/stream`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"question": "What is the capital of France?",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"userLocation": "optional city, region, country",
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
|
||||
- `question` is required, trimmed by the server, and must not be empty.
|
||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
||||
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
||||
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
||||
|
||||
## Event Stream Contract
|
||||
|
||||
Event order:
|
||||
@@ -207,21 +173,18 @@ 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), `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.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||
- 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.
|
||||
@@ -275,63 +238,3 @@ event: done
|
||||
data: {"type":"done","text":"Hello world"}
|
||||
|
||||
```
|
||||
|
||||
## Sybil Agent WebSocket
|
||||
|
||||
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
|
||||
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
|
||||
connected after each assistant turn and receives later background results. Sending
|
||||
messages uses `POST /v1/agent/messages`.
|
||||
|
||||
The browser must send this first frame within five seconds:
|
||||
|
||||
```json
|
||||
{ "type": "auth", "token": "Sybil token", "after": "43" }
|
||||
```
|
||||
|
||||
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
|
||||
the last successfully merged cursor. Invalid credentials close with 4401, malformed
|
||||
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
|
||||
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
|
||||
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
|
||||
browser credentials are not forwarded to the agent.
|
||||
|
||||
Native `pi-room.v1` frames are forwarded after authentication. This protocol
|
||||
identifier is retained for compatibility with existing clients:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "messages",
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
|
||||
"cursor": "44",
|
||||
"beforeCursor": "44",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
|
||||
```
|
||||
|
||||
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
|
||||
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
|
||||
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
|
||||
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
|
||||
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
||||
applying its entire frame, even when it contains no visible messages.
|
||||
|
||||
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
||||
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
||||
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
||||
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
|
||||
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
|
||||
cursor. Missing agent configuration closes with 4503.
|
||||
|
||||
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
||||
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||
|
||||
+21
-5
@@ -1,5 +1,21 @@
|
||||
ASC_KEY_ID=
|
||||
ASC_ISSUER_ID=
|
||||
ASC_KEY=
|
||||
MATCH_PASSWORD=
|
||||
MATCH_GIT_BASIC_AUTHORIZATION=
|
||||
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_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||
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=
|
||||
|
||||
@@ -21,12 +21,6 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||
|
||||
## Release Workflow
|
||||
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
|
||||
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
|
||||
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
|
||||
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
|
||||
|
||||
## App Structure
|
||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||
- Shared iOS app code lives in Swift package:
|
||||
@@ -57,5 +51,4 @@ 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`
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -8,7 +8,7 @@ struct SybilApp: App
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
@@ -24,18 +24,9 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
@@ -65,22 +56,11 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if scene is UIWindowScene, session.role == .windowApplication {
|
||||
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
|
||||
@@ -11,13 +11,6 @@ targets:
|
||||
dependencies:
|
||||
- package: Sybil
|
||||
product: Sybil
|
||||
- target: SybilMacQuickQuestion
|
||||
destinationFilters: [macCatalyst]
|
||||
embed: true
|
||||
link: false
|
||||
codeSign: true
|
||||
copy:
|
||||
destination: plugins
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
@@ -31,21 +24,14 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: "1.13.2"
|
||||
MARKETING_VERSION: "1.10"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
|
||||
|
||||
schemes:
|
||||
Sybil:
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import AppKit
|
||||
import Carbon
|
||||
|
||||
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
||||
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
||||
@MainActor
|
||||
@objc(SybilMacQuickQuestionPlugin)
|
||||
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
private var panel: QuickQuestionPanel?
|
||||
private var panelView: QuickQuestionPanelView?
|
||||
private var hotKey: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
private var outsideClickMonitor: Any?
|
||||
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
private var isDismissing = false
|
||||
private var shortcutAvailable = false
|
||||
private var windowlessActivity: NSObjectProtocol?
|
||||
private var latestState = SybilQuickQuestionPanelState()
|
||||
private var previousApplication: NSRunningApplication?
|
||||
|
||||
required override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
var isVisible: Bool { panel?.isVisible == true }
|
||||
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||
self.delegate = delegate
|
||||
if windowlessActivity == nil {
|
||||
// Keep the hotkey service alive with no windows. This option does
|
||||
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
||||
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: .automaticTerminationDisabled,
|
||||
reason: "Quick Question global shortcut"
|
||||
)
|
||||
}
|
||||
guard eventHandler == nil else { return noErr }
|
||||
var eventTypes = [
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
||||
]
|
||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||
let handlerStatus = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ _, event, context in
|
||||
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
||||
var identifier = EventHotKeyID()
|
||||
let status = GetEventParameter(
|
||||
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
||||
)
|
||||
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
||||
return OSStatus(eventNotHandledErr)
|
||||
}
|
||||
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
||||
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
||||
// Application event handlers run on the main event loop.
|
||||
MainActor.assumeIsolated {
|
||||
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
||||
plugin.delegate?.quickQuestionToggleRequested()
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
eventTypes.count, &eventTypes, context, &eventHandler
|
||||
)
|
||||
guard handlerStatus == noErr else { return handlerStatus }
|
||||
let status = RegisterEventHotKey(
|
||||
UInt32(kVK_Space), UInt32(optionKey),
|
||||
EventHotKeyID(signature: 0x53595151, id: 1),
|
||||
GetApplicationEventTarget(), 0, &hotKey
|
||||
)
|
||||
shortcutAvailable = status == noErr
|
||||
return status
|
||||
}
|
||||
|
||||
func updateState(_ data: Data) {
|
||||
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
||||
// Updating a background request must not create a non-hosting window.
|
||||
// The controller creates/orders the panel only after UIKit is foreground.
|
||||
latestState = state
|
||||
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
||||
if isVisible { resizePanel() }
|
||||
}
|
||||
|
||||
func show() {
|
||||
createPanelIfNeeded()
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
||||
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
||||
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
||||
let width = min(680, visibleFrame.width - 48)
|
||||
panel.setContentSize(NSSize(width: width, height: 240))
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
||||
let y = max(visibleFrame.minY + 24, top - height)
|
||||
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
||||
// The controller has verified UIKit foreground readiness. A
|
||||
// nonactivating panel can then take focus without raising a workspace.
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
panel.makeFirstResponder(panelView.promptView)
|
||||
panelView.preparePromptForPresentation()
|
||||
installOutsideClickMonitor()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
dismissPanel(restoringPreviousApplication: true)
|
||||
}
|
||||
|
||||
func activateForQuickQuestion() {
|
||||
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
||||
frontmost != NSRunningApplication.current {
|
||||
previousApplication = frontmost
|
||||
}
|
||||
// Do not order a window here. Catalyst must finish its own foreground
|
||||
// transition before the controller calls show().
|
||||
NSApp.activate()
|
||||
}
|
||||
|
||||
func activateMainWindow() {
|
||||
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
||||
previousApplication = nil
|
||||
NSApp.activate()
|
||||
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func dismissPanel(restoringPreviousApplication: Bool) {
|
||||
guard !isDismissing else { return }
|
||||
let wasVisible = isVisible
|
||||
isDismissing = true
|
||||
if wasVisible { panel?.orderOut(nil) }
|
||||
removeOutsideClickMonitor()
|
||||
isDismissing = false
|
||||
let applicationToRestore = previousApplication
|
||||
previousApplication = nil
|
||||
if restoringPreviousApplication, NSApp.isActive,
|
||||
let applicationToRestore, !applicationToRestore.isTerminated {
|
||||
NSApp.yieldActivation(to: applicationToRestore)
|
||||
applicationToRestore.activate(options: [])
|
||||
}
|
||||
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
previousApplication = nil
|
||||
hide()
|
||||
removeOutsideClickMonitor()
|
||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||
if let eventHandler { RemoveEventHandler(eventHandler) }
|
||||
hotKey = nil
|
||||
eventHandler = nil
|
||||
hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
if let windowlessActivity {
|
||||
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
||||
}
|
||||
windowlessActivity = nil
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
// Focus is already moving to a window/app the user chose.
|
||||
dismissPanel(restoringPreviousApplication: false)
|
||||
}
|
||||
|
||||
private func createPanelIfNeeded() {
|
||||
guard panel == nil else { return }
|
||||
let panel = QuickQuestionPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
||||
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
||||
)
|
||||
panel.title = "Quick Question"
|
||||
panel.level = .floating
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||
panel.isFloatingPanel = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
panel.worksWhenModal = true
|
||||
panel.isReleasedWhenClosed = false
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.appearance = NSAppearance(named: .darkAqua)
|
||||
panel.delegate = self
|
||||
panel.onDismiss = { [weak self] in self?.hide() }
|
||||
let view = QuickQuestionPanelView()
|
||||
view.delegate = delegate
|
||||
view.onDismiss = { [weak self] in self?.hide() }
|
||||
panel.contentView = view
|
||||
panelView = view
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func resizePanel() {
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
guard abs(height - panel.frame.height) > 1 else { return }
|
||||
var frame = panel.frame
|
||||
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
||||
frame.size.height = height
|
||||
panel.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
private func installOutsideClickMonitor() {
|
||||
guard outsideClickMonitor == nil else { return }
|
||||
// Mouse-only monitoring does not require Accessibility or Input
|
||||
// Monitoring permission. No global keyboard event tap is installed.
|
||||
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.hide() }
|
||||
}
|
||||
}
|
||||
|
||||
private func removeOutsideClickMonitor() {
|
||||
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
||||
outsideClickMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class QuickQuestionPanel: NSPanel {
|
||||
var onDismiss: (() -> Void)?
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
|
||||
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
let promptView = QuickQuestionPromptView()
|
||||
private let stack = NSStackView()
|
||||
private let answerView = NSTextView()
|
||||
private let answerScroll = NSScrollView()
|
||||
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
||||
private let providerPopup = NSPopUpButton()
|
||||
private let modelPopup = NSPopUpButton()
|
||||
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
|
||||
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
|
||||
private let sendButton = NSButton()
|
||||
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
|
||||
private var panelState = SybilQuickQuestionPanelState()
|
||||
|
||||
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
material = .hudWindow
|
||||
blendingMode = .behindWindow
|
||||
state = .active
|
||||
wantsLayer = true
|
||||
layer?.cornerRadius = 18
|
||||
layer?.masksToBounds = true
|
||||
layer?.borderWidth = 1
|
||||
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
|
||||
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.detachesHiddenViews = true
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
|
||||
])
|
||||
|
||||
addRow(makeHeader())
|
||||
configureTextView(promptView, fontSize: 22)
|
||||
promptView.delegate = self
|
||||
promptView.isRichText = false
|
||||
promptView.allowsUndo = true
|
||||
promptView.setAccessibilityLabel("Quick question prompt")
|
||||
promptView.onSubmit = { [weak self] in self?.submit() }
|
||||
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
|
||||
let promptScroll = makeScrollView(documentView: promptView)
|
||||
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
|
||||
addRow(promptScroll)
|
||||
|
||||
configureTextView(answerView, fontSize: 15)
|
||||
answerView.isEditable = false
|
||||
answerView.isSelectable = true
|
||||
answerView.setAccessibilityLabel("Quick question answer")
|
||||
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
|
||||
answerScroll.drawsBackground = false
|
||||
answerScroll.hasVerticalScroller = true
|
||||
answerScroll.autohidesScrollers = true
|
||||
answerScroll.documentView = answerView
|
||||
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
|
||||
addRow(answerScroll)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.maximumNumberOfLines = 3
|
||||
addRow(statusLabel)
|
||||
addRow(makeControls())
|
||||
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
|
||||
hint.font = .systemFont(ofSize: 11)
|
||||
hint.textColor = .secondaryLabelColor
|
||||
addRow(hint)
|
||||
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
|
||||
let previous = panelState
|
||||
panelState = next
|
||||
if promptView.string != next.prompt, !promptView.hasMarkedText() {
|
||||
promptView.string = next.prompt
|
||||
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
|
||||
}
|
||||
promptView.isEditable = !next.isBusy
|
||||
promptView.needsDisplay = true
|
||||
if previous.answer != next.answer {
|
||||
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
|
||||
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
|
||||
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
|
||||
}
|
||||
answerScroll.isHidden = next.answer.isEmpty
|
||||
|
||||
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
|
||||
providerPopup.removeAllItems()
|
||||
for provider in next.providers {
|
||||
providerPopup.addItem(withTitle: provider.title)
|
||||
providerPopup.lastItem?.representedObject = provider.id
|
||||
}
|
||||
}
|
||||
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
|
||||
providerPopup.selectItem(at: index)
|
||||
}
|
||||
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
|
||||
modelPopup.removeAllItems()
|
||||
var models = next.models
|
||||
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
|
||||
modelPopup.addItems(withTitles: models)
|
||||
modelPopup.selectItem(withTitle: next.model)
|
||||
}
|
||||
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||
convertButton.isEnabled = next.canConvert
|
||||
sendButton.isEnabled = next.canSend
|
||||
openAppButton.isHidden = next.isAuthenticated
|
||||
convertButton.isHidden = !next.isAuthenticated
|
||||
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
|
||||
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
|
||||
|
||||
let status: String
|
||||
if let error = next.error {
|
||||
status = error
|
||||
statusLabel.textColor = .systemRed
|
||||
} else {
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
if next.isCheckingSession {
|
||||
status = "Connecting to Sybil…"
|
||||
} else if !next.isAuthenticated {
|
||||
status = "Connect in Sybil before asking a question."
|
||||
} else if next.isConverting {
|
||||
status = "Opening in chat…"
|
||||
} else if next.isSending {
|
||||
status = next.toolSummaries.last ?? "Thinking…"
|
||||
} else {
|
||||
status = ""
|
||||
}
|
||||
}
|
||||
statusLabel.stringValue = status
|
||||
statusLabel.isHidden = status.isEmpty
|
||||
}
|
||||
|
||||
func preparePromptForPresentation() {
|
||||
// Select only when invoked, never on streamed updates or normal edits.
|
||||
promptView.selectAll(nil)
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !panelState.isBusy else { return }
|
||||
promptView.needsDisplay = true
|
||||
delegate?.quickQuestionPromptChanged(promptView.string)
|
||||
}
|
||||
|
||||
private func addRow(_ view: NSView) {
|
||||
stack.addArrangedSubview(view)
|
||||
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
|
||||
private func makeHeader() -> NSView {
|
||||
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
|
||||
icon.contentTintColor = .systemPurple
|
||||
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
||||
let title = NSTextField(labelWithString: "Quick Question")
|
||||
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
|
||||
shortcutLabel.textColor = .secondaryLabelColor
|
||||
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
|
||||
close.isBordered = false
|
||||
close.toolTip = "Close (Esc)"
|
||||
close.setAccessibilityLabel("Close Quick Question")
|
||||
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
|
||||
row.spacing = 8
|
||||
row.alignment = .centerY
|
||||
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeControls() -> NSView {
|
||||
providerPopup.target = self
|
||||
providerPopup.action = #selector(changeProvider)
|
||||
providerPopup.setAccessibilityLabel("Quick question provider")
|
||||
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||
modelPopup.target = self
|
||||
modelPopup.action = #selector(changeModel)
|
||||
modelPopup.setAccessibilityLabel("Quick question model")
|
||||
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
|
||||
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
convertButton.target = self
|
||||
convertButton.action = #selector(convertToChat)
|
||||
convertButton.bezelStyle = .rounded
|
||||
openAppButton.target = self
|
||||
openAppButton.action = #selector(openApp)
|
||||
openAppButton.bezelStyle = .rounded
|
||||
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
|
||||
sendButton.imagePosition = .imageOnly
|
||||
sendButton.bezelStyle = .circular
|
||||
sendButton.contentTintColor = .systemPurple
|
||||
sendButton.target = self
|
||||
sendButton.action = #selector(submit)
|
||||
sendButton.setAccessibilityLabel("Ask Quick Question")
|
||||
sendButton.toolTip = "Ask (Return)"
|
||||
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
||||
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
|
||||
row.spacing = 8
|
||||
row.alignment = .centerY
|
||||
row.detachesHiddenViews = true
|
||||
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
||||
return row
|
||||
}
|
||||
|
||||
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
|
||||
view.drawsBackground = false
|
||||
view.textColor = .labelColor
|
||||
view.insertionPointColor = .labelColor
|
||||
view.font = .systemFont(ofSize: fontSize)
|
||||
view.textContainerInset = NSSize(width: 0, height: 4)
|
||||
view.isHorizontallyResizable = false
|
||||
view.isVerticallyResizable = true
|
||||
view.autoresizingMask = [.width]
|
||||
view.textContainer?.widthTracksTextView = true
|
||||
view.textContainer?.lineFragmentPadding = 0
|
||||
}
|
||||
|
||||
private func makeScrollView(documentView: NSView) -> NSScrollView {
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.documentView = documentView
|
||||
return scroll
|
||||
}
|
||||
|
||||
private static func renderAnswer(_ text: String) -> NSAttributedString {
|
||||
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 4
|
||||
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
|
||||
for run in parsed.runs {
|
||||
let intent = run.inlinePresentationIntent ?? []
|
||||
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
|
||||
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
|
||||
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
|
||||
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
@objc private func closePanel() { onDismiss?() }
|
||||
@objc private func changeProvider() {
|
||||
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
|
||||
delegate?.quickQuestionProviderChanged(provider)
|
||||
}
|
||||
@objc private func changeModel() {
|
||||
guard let model = modelPopup.titleOfSelectedItem else { return }
|
||||
delegate?.quickQuestionModelChanged(model)
|
||||
}
|
||||
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
|
||||
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
|
||||
@objc private func submit() {
|
||||
guard panelState.canSend, !promptView.hasMarkedText() else { return }
|
||||
delegate?.quickQuestionSubmitRequested()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class QuickQuestionPromptView: NSTextView {
|
||||
var onSubmit: (() -> Void)?
|
||||
var onToggle: (() -> Void)?
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
super.draw(dirtyRect)
|
||||
guard string.isEmpty else { return }
|
||||
("Ask anything…" as NSString).draw(
|
||||
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
|
||||
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
|
||||
)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
if event.keyCode == 49, modifiers == .option {
|
||||
if !event.isARepeat { onToggle?() }
|
||||
return
|
||||
}
|
||||
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
|
||||
if !event.isARepeat { onSubmit?() }
|
||||
return
|
||||
}
|
||||
// Let the text input system handle Shift+Return and IME confirmation.
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
targets:
|
||||
SybilMacQuickQuestion:
|
||||
type: bundle
|
||||
platform: macOS
|
||||
deploymentTarget: "15.0"
|
||||
sources:
|
||||
- Sources
|
||||
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
|
||||
dependencies:
|
||||
- sdk: AppKit.framework
|
||||
- sdk: Carbon.framework
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
|
||||
PRODUCT_NAME: SybilMacQuickQuestion
|
||||
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
|
||||
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
|
||||
MACH_O_TYPE: mh_bundle
|
||||
SKIP_INSTALL: YES
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "2.237.0"
|
||||
gem "fastlane", "~> 2.227"
|
||||
|
||||
+62
-75
@@ -1,49 +1,46 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
CFPropertyList (3.0.9)
|
||||
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.4.0)
|
||||
aws-partitions (1.1274.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
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.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.228.1)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (1.6.0)
|
||||
logger
|
||||
excon (0.109.0)
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -73,45 +70,42 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
fastlane (2.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.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, < 2.0.0)
|
||||
excon (>= 0.71.0, < 1.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.1.0)
|
||||
fastlane-sirp (>= 1.0.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.3.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
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)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
nkf (~> 0.2.0)
|
||||
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)
|
||||
@@ -126,46 +120,41 @@ 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.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
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)
|
||||
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-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.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)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
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)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
@@ -173,24 +162,22 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
json (2.7.6)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -202,10 +189,11 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
@@ -218,16 +206,15 @@ 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.28.1)
|
||||
xcodeproj (1.27.0)
|
||||
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)
|
||||
@@ -238,7 +225,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.237.0)
|
||||
fastlane (~> 2.227)
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
|
||||
@@ -24,8 +24,7 @@ let package = Package(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "SybilTests",
|
||||
dependencies: ["Sybil"]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "character-still.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
@@ -78,7 +78,6 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
@@ -86,11 +85,7 @@ public struct SplitView: View {
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
@@ -106,18 +101,12 @@ public struct SplitView: View {
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: isQuickQuestionPresented) { _, presented in
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
@@ -162,13 +151,8 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
SybilMacQuickQuestionController.shared.show()
|
||||
#else
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
@@ -177,57 +161,23 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
public static let workspaceWindowID = "sybil-workspace"
|
||||
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
windowCommands.newChatOrWindow(
|
||||
newChat: keyboardActions?.newChat,
|
||||
openWindow: openNewWindow
|
||||
)
|
||||
#else
|
||||
keyboardActions?.newChat()
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||
#else
|
||||
.disabled(keyboardActions == nil)
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button("New Window", action: openNewWindow)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||
#else
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Divider()
|
||||
Button("Quick Question") {
|
||||
SybilMacQuickQuestionController.shared.toggle()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: .option)
|
||||
#endif
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
@@ -244,13 +194,6 @@ public struct SybilCommands: Commands {
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func openNewWindow() {
|
||||
// Targeting a WindowGroup without a value creates a fresh scene every
|
||||
// time, even when another workspace window is already open.
|
||||
openWindow(id: Self.workspaceWindowID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActions {
|
||||
|
||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -40,48 +40,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
|
||||
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
|
||||
var query = [URLQueryItem(name: "limit", value: "200")]
|
||||
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
|
||||
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
|
||||
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
|
||||
try page.validate()
|
||||
return page
|
||||
}
|
||||
|
||||
func agentReadState() async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: requestID)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
task.resume()
|
||||
let socket = AgentURLSocket(task: task)
|
||||
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
|
||||
catch { await socket.close(); throw error }
|
||||
return socket
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
@@ -91,15 +49,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return response.items
|
||||
}
|
||||
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
|
||||
try await request(
|
||||
"/v1/workspace-items",
|
||||
method: "GET",
|
||||
queryItems: [URLQueryItem(name: "q", value: query)],
|
||||
responseType: WorkspaceSearchResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
@@ -125,16 +74,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/fork",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
@@ -244,29 +183,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||
}
|
||||
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
let request = try makeRequest(
|
||||
path: "/v1/quick-questions/stream",
|
||||
method: "POST",
|
||||
body: AnyEncodable(body),
|
||||
acceptsSSE: true
|
||||
)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.network,
|
||||
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
||||
}
|
||||
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
@@ -341,10 +257,9 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: AnyEncodable? = nil,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||
|
||||
SybilLog.debug(
|
||||
SybilLog.network,
|
||||
@@ -396,14 +311,12 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
path: String,
|
||||
method: String,
|
||||
body: AnyEncodable?,
|
||||
acceptsSSE: Bool,
|
||||
queryItems: [URLQueryItem] = []
|
||||
acceptsSSE: Bool
|
||||
) throws -> URLRequest {
|
||||
let url = try buildURL(path: path, queryItems: queryItems)
|
||||
let url = try buildURL(path: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
|
||||
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
|
||||
request.timeoutInterval = 120
|
||||
|
||||
if acceptsSSE {
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
@@ -423,7 +336,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return request
|
||||
}
|
||||
|
||||
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
|
||||
private func buildURL(path: String) throws -> URL {
|
||||
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidBaseURL
|
||||
}
|
||||
@@ -434,9 +347,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
basePath.removeLast()
|
||||
}
|
||||
components.path = "\(basePath)/\(trimmedPath)"
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = (components.queryItems ?? []) + queryItems
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidBaseURL
|
||||
@@ -754,12 +664,6 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var question: String
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
var provider: Provider?
|
||||
@@ -771,10 +675,6 @@ private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct ChatForkBody: Encodable {
|
||||
var messageId: String?
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Foundation
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(
|
||||
title: String?,
|
||||
@@ -12,7 +11,6 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
@@ -29,10 +27,6 @@ protocol SybilAPIClienting: Sendable {
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct AgentFailure: LocalizedError, Sendable {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
// Compare decimal strings without losing precision through Double or overflowing Int.
|
||||
enum AgentCursor {
|
||||
static func normalize(_ value: String) throws -> String {
|
||||
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
|
||||
throw AgentFailure(message: "Invalid Agent cursor.")
|
||||
}
|
||||
let trimmed = value.drop(while: { $0 == "0" })
|
||||
return trimmed.isEmpty ? "0" : String(trimmed)
|
||||
}
|
||||
static func less(_ a: String, _ b: String) -> Bool {
|
||||
a.count == b.count ? a < b : a.count < b.count
|
||||
}
|
||||
static func advance(_ current: String, _ incoming: String) throws -> String {
|
||||
let value = try normalize(incoming)
|
||||
return less(current, value) ? value : current
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentActivity: Codable, Equatable, Sendable {
|
||||
struct Tool: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var name: String
|
||||
var status: String
|
||||
var output: String?
|
||||
}
|
||||
struct Wait: Codable, Equatable, Sendable {
|
||||
var due: Double
|
||||
var reason: String?
|
||||
}
|
||||
var state: String = "idle"
|
||||
var isThinking = false
|
||||
var queued = 0
|
||||
var backgroundWorkers = 0
|
||||
var tools: [Tool] = []
|
||||
var queuedMessages: [Message] = []
|
||||
var wait: Wait?
|
||||
var error: String?
|
||||
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
|
||||
}
|
||||
|
||||
struct AgentDescriptor: Decodable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
var type: String
|
||||
var model: String
|
||||
var `protocol`: String
|
||||
var activity: AgentActivity
|
||||
var websocket: String
|
||||
}
|
||||
struct AgentPage: Codable, Sendable {
|
||||
var conversationId: String
|
||||
var messages: [Message]
|
||||
var cursor: String?
|
||||
var beforeCursor: String?
|
||||
var hasMore: Bool
|
||||
|
||||
func validate() throws {
|
||||
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
|
||||
throw AgentFailure(message: "Invalid Agent history.")
|
||||
}
|
||||
if let cursor { _ = try AgentCursor.normalize(cursor) }
|
||||
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
|
||||
}
|
||||
}
|
||||
struct AgentReadState: Codable, Sendable {
|
||||
var cursor: String
|
||||
var updatedAt: String?
|
||||
}
|
||||
struct AgentReceipt: Codable, Sendable {
|
||||
var id: Int64
|
||||
var status: String
|
||||
var messageId: String
|
||||
func validate(requestID: String) throws {
|
||||
guard id > 0, id <= 9_007_199_254_740_991,
|
||||
["queued", "placed", "done", "unanswered"].contains(status),
|
||||
messageId == "inbound:sybil:\(requestID)" else {
|
||||
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
static func decode(_ data: Data) throws -> AgentEvent? {
|
||||
struct Envelope: Decodable {
|
||||
var type: String
|
||||
var conversationId: String?
|
||||
var activity: AgentActivity?
|
||||
var error: String?
|
||||
}
|
||||
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
|
||||
switch envelope.type {
|
||||
case "ready": return .ready(envelope.conversationId)
|
||||
case "messages":
|
||||
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
|
||||
try page.validate()
|
||||
return .messages(page)
|
||||
case "activity":
|
||||
guard let id = envelope.conversationId, let activity = envelope.activity else {
|
||||
throw AgentFailure(message: "Invalid Agent activity frame.")
|
||||
}
|
||||
return .activity(id, activity)
|
||||
case "pong", "heartbeat": return .heartbeat
|
||||
case "error": return .error(envelope.error ?? "Agent connection error.")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentCoding {
|
||||
static func decoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let text = try container.decode(String.self)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: text) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
guard let date = formatter.date(from: text) else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentEntryCursor: String? {
|
||||
let value = metadata?.objectValue?["entryId"]
|
||||
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
|
||||
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
|
||||
return String(Int64(number))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var isAgentPublicReply: Bool {
|
||||
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
|
||||
}
|
||||
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
|
||||
}
|
||||
|
||||
struct AgentRoomState: Equatable, Sendable {
|
||||
var conversationID: String?
|
||||
var messages: [Message] = []
|
||||
var cursor = "0"
|
||||
var readCursor = "0"
|
||||
var activity = AgentActivity()
|
||||
var unreadCount: Int {
|
||||
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
|
||||
}
|
||||
mutating func checkConversation(_ id: String) throws {
|
||||
guard conversationID == nil || conversationID == id else {
|
||||
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
|
||||
}
|
||||
conversationID = id
|
||||
}
|
||||
mutating func apply(_ page: AgentPage) throws {
|
||||
try page.validate()
|
||||
var next = self
|
||||
try next.checkConversation(page.conversationId)
|
||||
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
|
||||
next.messages = Self.merge(messages, page.messages)
|
||||
self = next
|
||||
}
|
||||
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
|
||||
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
for var message in incoming {
|
||||
if let old = byID[message.id] {
|
||||
let prior = old.metadata?.objectValue ?? [:]
|
||||
let next = message.metadata?.objectValue ?? [:]
|
||||
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
|
||||
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
|
||||
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
|
||||
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
|
||||
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
|
||||
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
|
||||
message.metadata = .object(merged)
|
||||
message.createdAt = min(old.createdAt, message.createdAt)
|
||||
if stale { message.content = old.content; message.name = old.name }
|
||||
}
|
||||
}
|
||||
byID[message.id] = message
|
||||
}
|
||||
return byID.values.sorted {
|
||||
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
|
||||
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter only the transcript projection, before windowing. Keep the room's raw
|
||||
// messages and cursors intact for upserts, replay, unread state and activity.
|
||||
struct AgentTranscript {
|
||||
let messages: [Message]
|
||||
let startIndex: Int
|
||||
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
|
||||
|
||||
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
|
||||
let visible = messages.filter {
|
||||
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
|
||||
}
|
||||
self.messages = visible
|
||||
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
|
||||
?? max(0, visible.count - limit)
|
||||
}
|
||||
}
|
||||
|
||||
// The transcript latch is independent of the truthful sidebar/worker activity.
|
||||
struct AgentPresentation {
|
||||
private(set) var suppressed = false
|
||||
private(set) var busySince: Date?
|
||||
private var seenTools: Set<String> = []
|
||||
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
||||
|
||||
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
||||
switch event {
|
||||
case let .messages(page):
|
||||
let existing = Set(before.messages.map(\.id))
|
||||
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
|
||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||
if !replay {
|
||||
for message in fresh {
|
||||
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
||||
else if message.role == .user { suppressed = false }
|
||||
else if message.toolCallMetadata?.status == "initiated",
|
||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
||||
}
|
||||
}
|
||||
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
|
||||
case let .activity(_, activity):
|
||||
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
|
||||
seenTools.formUnion(activity.tools.map(\.id))
|
||||
default: break
|
||||
}
|
||||
reconcile(activity: after.activity, now: now)
|
||||
}
|
||||
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
|
||||
private mutating func reconcile(activity: AgentActivity, now: Date) {
|
||||
if suppressed || !activity.isBusy { busySince = nil }
|
||||
else if busySince == nil { busySince = now }
|
||||
}
|
||||
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct AgentOutboxRow: Codable, Equatable, Sendable {
|
||||
var requestID: String
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var status: String = "sending"
|
||||
var seenOnServer = false
|
||||
var confirmed = false
|
||||
var error: String?
|
||||
var id: String { "inbound:sybil:\(requestID)" }
|
||||
var message: Message {
|
||||
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
|
||||
if let error { info["error"] = .string(error) }
|
||||
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOutboxStorage: Sendable {
|
||||
var read: @Sendable () throws -> [AgentOutboxRow]
|
||||
var write: @Sendable ([AgentOutboxRow]) throws -> Void
|
||||
|
||||
static func file(baseURL: URL) -> Self {
|
||||
// Separate installations, but never persist tokens in a filename or payload.
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
|
||||
let url = directory.appendingPathComponent("\(key).json")
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
|
||||
}, write: { rows in
|
||||
let data = try JSONEncoder().encode(rows)
|
||||
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
|
||||
throw AgentFailure(message: "Retry pending messages before sending more.")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SybilAgentRoom {
|
||||
struct Snapshot: Equatable {
|
||||
var room = AgentRoomState()
|
||||
var isTyping = false
|
||||
var entranceIDs: Set<String> = []
|
||||
var revision = 0
|
||||
}
|
||||
private(set) var snapshot = Snapshot()
|
||||
private(set) var connection = "Offline"
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
var composer = ""
|
||||
var isObscured = false
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentOutboxStorage?
|
||||
@ObservationIgnored private var storageReadable = true
|
||||
@ObservationIgnored private var pending = AgentRoomState()
|
||||
@ObservationIgnored private var presenter = AgentPresentation()
|
||||
@ObservationIgnored private var pendingEntrances: Set<String> = []
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var connectionTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var batchTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var typingTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var readTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored private var socket: (any AgentSocket)?
|
||||
@ObservationIgnored private var monitor: NWPathMonitor?
|
||||
@ObservationIgnored private var lastPacket = Date()
|
||||
@ObservationIgnored private var socketStarted = Date()
|
||||
@ObservationIgnored private var desiredRead = "0"
|
||||
@ObservationIgnored private var acknowledgedRead = "0"
|
||||
@ObservationIgnored private var hasLoaded = false
|
||||
@ObservationIgnored private var syncing = false
|
||||
@ObservationIgnored private var wasNetworkOffline = false
|
||||
|
||||
var isBusy: Bool { snapshot.room.activity.isBusy }
|
||||
var unreadCount: Int { snapshot.room.unreadCount }
|
||||
var messages: [Message] {
|
||||
let committed = Set(snapshot.room.messages.map(\.id))
|
||||
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
||||
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
||||
for row in outbox {
|
||||
if let index = messages.firstIndex(where: { $0.id == row.id }) {
|
||||
messages[index].createdAt = row.createdAt
|
||||
if !row.confirmed {
|
||||
var info = messages[index].metadata?.objectValue ?? [:]
|
||||
info["status"] = .string(row.status)
|
||||
if let error = row.error { info["error"] = .string(error) }
|
||||
messages[index].metadata = .object(info)
|
||||
}
|
||||
} else { messages.append(row.message) }
|
||||
}
|
||||
return AgentRoomState.merge([], messages)
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
|
||||
stop()
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
var restored = row
|
||||
if !restored.seenOnServer {
|
||||
restored.status = "failed"
|
||||
restored.error = "Delivery was interrupted. Retry to confirm."
|
||||
}
|
||||
return restored
|
||||
}
|
||||
} catch {
|
||||
outbox = []; storageReadable = false
|
||||
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
stop()
|
||||
client = nil
|
||||
}
|
||||
|
||||
func stop() {
|
||||
generation = UUID()
|
||||
connectionTask?.cancel(); connectionTask = nil
|
||||
maintenanceTask?.cancel(); maintenanceTask = nil
|
||||
batchTask?.cancel(); batchTask = nil
|
||||
typingTask?.cancel(); typingTask = nil
|
||||
readTask?.cancel(); readTask = nil
|
||||
sends.values.forEach { $0.cancel() }; sends = [:]
|
||||
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
|
||||
outbox[index].status = "failed"
|
||||
outbox[index].error = "Delivery was interrupted. Retry to confirm."
|
||||
}
|
||||
if !outbox.isEmpty { try? persist() }
|
||||
monitor?.cancel(); monitor = nil
|
||||
let old = socket; socket = nil
|
||||
Task { await old?.close() }
|
||||
syncing = false
|
||||
pendingEntrances = []
|
||||
connection = "Offline"
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
guard isActive != active else { return }
|
||||
isActive = active
|
||||
if active { start() }
|
||||
else {
|
||||
// Publish successfully merged input before cancelling; replay resumes here.
|
||||
flush()
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
func retryConnection() { flush(); stop(); if isActive { start() } }
|
||||
|
||||
private func start() {
|
||||
guard isActive, let client, connectionTask == nil else { return }
|
||||
let run = generation
|
||||
isLoading = !hasLoaded
|
||||
connection = "Connecting"
|
||||
let monitor = NWPathMonitor()
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.generation == run else { return }
|
||||
let recovered = self.wasNetworkOffline && satisfied
|
||||
self.wasNetworkOffline = !satisfied
|
||||
if recovered { self.retryConnection() }
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
|
||||
self.monitor = monitor
|
||||
connectionTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var delay = 1
|
||||
while !Task.isCancelled, self.generation == run {
|
||||
do {
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
guard self.generation == run else { return }
|
||||
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.hasLoaded = true; self.isLoading = false; self.flush()
|
||||
let socket = try await client.agentSocket(after: self.pending.cursor)
|
||||
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
|
||||
self.socket = socket
|
||||
self.socketStarted = Date(); self.lastPacket = Date()
|
||||
while !Task.isCancelled, self.generation == run {
|
||||
let data = try await socket.receive()
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.lastPacket = Date()
|
||||
guard let event = try AgentEvent.decode(data) else { continue }
|
||||
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
|
||||
try self.receive(event)
|
||||
}
|
||||
} catch {
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
let failedSocket = self.socket
|
||||
let code = await failedSocket?.closeCode()
|
||||
await failedSocket?.close()
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.socket = nil
|
||||
self.isLoading = false
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
|
||||
self.connection = code == 4401 ? "Offline" : "Reconnecting"
|
||||
if code == 4401 { return }
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
delay = min(delay * 2, 30)
|
||||
}
|
||||
}
|
||||
maintenanceTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
guard let self, self.generation == run, !Task.isCancelled else { return }
|
||||
do {
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.flushRead()
|
||||
if self.connection != "Connected" {
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
}
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
if let socket = self.socket {
|
||||
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
|
||||
await socket.close()
|
||||
} else { try await socket.send("{\"type\":\"ping\"}") }
|
||||
}
|
||||
} catch {
|
||||
if self.generation == run { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
|
||||
let run = run ?? generation
|
||||
guard generation == run, !Task.isCancelled, !syncing else { return }
|
||||
syncing = true
|
||||
defer { if generation == run { syncing = false } }
|
||||
var cursor = pending.cursor
|
||||
while !Task.isCancelled, generation == run {
|
||||
let page = try await client.agentMessages(after: cursor, before: nil)
|
||||
guard generation == run, !Task.isCancelled else { return }
|
||||
try page.validate()
|
||||
let next = try AgentCursor.normalize(page.cursor ?? cursor)
|
||||
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
|
||||
throw AgentFailure(message: "Agent history did not advance its cursor.")
|
||||
}
|
||||
try receive(.messages(page), replay: replay)
|
||||
cursor = next
|
||||
if !page.hasMore { return }
|
||||
}
|
||||
}
|
||||
|
||||
func receive(_ event: AgentEvent, replay: Bool = false) throws {
|
||||
let before = pending
|
||||
var next = pending
|
||||
switch event {
|
||||
case let .messages(page): try next.apply(page)
|
||||
case let .activity(id, activity):
|
||||
try next.checkConversation(id)
|
||||
next.activity = activity
|
||||
case let .ready(id): if let id { try next.checkConversation(id) }
|
||||
case let .error(message): throw AgentFailure(message: message)
|
||||
case .heartbeat: return
|
||||
}
|
||||
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
|
||||
pending = next
|
||||
if !replay {
|
||||
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
|
||||
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
|
||||
}
|
||||
reconcileOutbox()
|
||||
schedulePublish()
|
||||
}
|
||||
|
||||
private func schedulePublish() {
|
||||
guard batchTask == nil else { return }
|
||||
batchTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func flush() {
|
||||
batchTask?.cancel(); batchTask = nil
|
||||
typingTask?.cancel(); typingTask = nil
|
||||
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
||||
pendingEntrances = []
|
||||
if let start = presenter.busySince, !snapshot.isTyping {
|
||||
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
|
||||
typingTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
|
||||
let read = try await client.agentReadState()
|
||||
guard generation == run else { return }
|
||||
try applyRead(read)
|
||||
}
|
||||
func applyRead(_ read: AgentReadState) throws {
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
|
||||
pending.readCursor = acknowledgedRead
|
||||
schedulePublish()
|
||||
}
|
||||
func markRead(renderedCursor: String) {
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return }
|
||||
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
private func flushRead() {
|
||||
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
let run = generation
|
||||
readTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.readTask = nil } }
|
||||
do {
|
||||
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
let captured = self.desiredRead
|
||||
let read = try await client.agentMarkRead(cursor: captured)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
|
||||
throw AgentFailure(message: "Agent read state was not acknowledged.")
|
||||
}
|
||||
try self.applyRead(read)
|
||||
}
|
||||
} catch { if self.generation == run { self.error = error.localizedDescription } }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String) -> Bool {
|
||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
|
||||
error = "Enter a message of at most 40,000 characters."; return false
|
||||
}
|
||||
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
|
||||
outbox.append(row)
|
||||
pendingEntrances.insert(row.id)
|
||||
presenter.sent(now: Date(), activity: pending.activity)
|
||||
flush()
|
||||
dispatch(row.id)
|
||||
return true
|
||||
}
|
||||
func retry(_ id: String) { dispatch(id) }
|
||||
private func persist() throws {
|
||||
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
|
||||
try storage.write(outbox.filter { !$0.confirmed })
|
||||
}
|
||||
private func dispatch(_ id: String) {
|
||||
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
||||
outbox[index].status = "sending"; outbox[index].error = nil
|
||||
do { try persist() }
|
||||
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
|
||||
let row = outbox[index], run = generation
|
||||
sends[id] = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.sends[id] = nil } }
|
||||
do {
|
||||
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
|
||||
try receipt.validate(requestID: row.requestID)
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
|
||||
self.outbox[index].seenOnServer = true
|
||||
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
|
||||
self.outbox[index].error = nil
|
||||
try? self.persist()
|
||||
} catch {
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
|
||||
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
|
||||
try? self.persist()
|
||||
}
|
||||
}
|
||||
}
|
||||
private func reconcileOutbox() {
|
||||
let committed = Set(pending.messages.map(\.id))
|
||||
let queued = Set(pending.activity.queuedMessages.map(\.id))
|
||||
var changed = false
|
||||
for index in outbox.indices {
|
||||
if committed.contains(outbox[index].id) {
|
||||
if !outbox[index].confirmed { changed = true }
|
||||
outbox[index].confirmed = true; outbox[index].seenOnServer = true
|
||||
outbox[index].status = "complete"; outbox[index].error = nil
|
||||
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
|
||||
changed = true
|
||||
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
||||
}
|
||||
}
|
||||
if changed { try? persist() }
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
protocol AgentSocket: Sendable {
|
||||
func receive() async throws -> Data
|
||||
func send(_ text: String) async throws
|
||||
func close() async
|
||||
func closeCode() async -> Int
|
||||
}
|
||||
|
||||
protocol AgentAPIClienting: Sendable {
|
||||
func agentDescriptor() async throws -> AgentDescriptor
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSocket(after: String) async throws -> any AgentSocket
|
||||
}
|
||||
|
||||
actor AgentURLSocket: AgentSocket {
|
||||
private let task: URLSessionWebSocketTask
|
||||
init(task: URLSessionWebSocketTask) { self.task = task }
|
||||
func receive() async throws -> Data {
|
||||
switch try await task.receive() {
|
||||
case let .string(text): return Data(text.utf8)
|
||||
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
|
||||
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
|
||||
}
|
||||
}
|
||||
func send(_ text: String) async throws { try await task.send(.string(text)) }
|
||||
func close() { task.cancel(with: .goingAway, reason: nil) }
|
||||
func closeCode() -> Int { task.closeCode.rawValue }
|
||||
}
|
||||
|
||||
extension APIConfiguration {
|
||||
func agentWebSocketURL() throws -> URL {
|
||||
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(parts.scheme), parts.host != nil,
|
||||
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
|
||||
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
|
||||
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
|
||||
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
|
||||
parts.query = nil
|
||||
parts.fragment = nil
|
||||
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
||||
return url
|
||||
}
|
||||
func agentAuthFrame(after: String) throws -> String {
|
||||
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value: JSONValue = .object([
|
||||
"type": .string("auth"),
|
||||
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
|
||||
"after": .string(try AgentCursor.normalize(after))
|
||||
])
|
||||
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SybilAgentView: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var topInset: CGFloat = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
@State private var scrollMetrics = AgentScrollMetrics()
|
||||
@State private var rowFrames: [String: CGRect] = [:]
|
||||
@State private var restoreAnchor: AgentReadingAnchor?
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
var body: some View {
|
||||
let rendered = room.snapshot
|
||||
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
||||
let allMessages = transcript.messages
|
||||
let startIndex = transcript.startIndex
|
||||
let displayed = transcript.displayed
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
|
||||
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
VStack(spacing: 14) {
|
||||
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, retry: { room.retry(message.id) })
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||
})
|
||||
}
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.id(message.id)
|
||||
}
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
}
|
||||
Color.clear.frame(height: 1).id(bottomID)
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
isScrolling = phase.isScrolling
|
||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
||||
}
|
||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top)
|
||||
} action: { previous, next in
|
||||
scrollMetrics = next
|
||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||
if atLatest, !isScrolling, restoreAnchor == nil,
|
||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Restore the exact pixel offset after insertion or text
|
||||
// reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
|
||||
restoreAnchor = nil
|
||||
} else {
|
||||
rowFrames = frames
|
||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
} label: {
|
||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||
.overlay(Capsule().stroke(SybilTheme.border))
|
||||
}
|
||||
.padding(14)
|
||||
.accessibilityIdentifier("agent-jump-latest")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
appeared = true
|
||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: allMessages.count) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: composerFocused) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
statusBar
|
||||
composer
|
||||
}
|
||||
.padding(.top, topInset)
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
.task(id: readKey) {
|
||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||
// the task, and cannot be acknowledged while still entering the layout.
|
||||
let cursor = rendered.room.cursor
|
||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return }
|
||||
room.markRead(renderedCursor: cursor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var statusBar: some View {
|
||||
let activity = room.snapshot.room.activity
|
||||
if let error = room.error ?? activity.error {
|
||||
HStack(alignment: .top) {
|
||||
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
Spacer(minLength: 4)
|
||||
Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold))
|
||||
}.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
} else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil {
|
||||
HStack(spacing: 10) {
|
||||
if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") }
|
||||
if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") }
|
||||
if let wait = activity.wait {
|
||||
Text(wait.reason ?? "Waiting")
|
||||
Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.horizontal, 16).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSendComposer: Bool {
|
||||
!room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private func submitComposer() {
|
||||
guard canSendComposer, room.send(room.composer) else { return }
|
||||
room.composer = ""
|
||||
atLatest = true
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAvatar: View {
|
||||
var body: some View {
|
||||
Image("character-still", bundle: .module).resizable().scaledToFill()
|
||||
.frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3)))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentMessageRow: View {
|
||||
var message: Message
|
||||
var retry: () -> Void
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if !own { AgentAvatar().padding(.bottom, 23) }
|
||||
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
Text(message.createdAt, style: .time)
|
||||
if own && status == "sending" { Text("Sending…") }
|
||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||
if own && status == "failed" {
|
||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
|
||||
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animate both occupied height and content, so incoming rows push older history up.
|
||||
private struct AgentGrowingLayout: Layout {
|
||||
var progress: CGFloat
|
||||
var animatableData: CGFloat { get { progress } set { progress = newValue } }
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
|
||||
}
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
private struct AgentEntrance: ViewModifier {
|
||||
var animate: Bool
|
||||
var reduceMotion: Bool
|
||||
@State private var entered = false
|
||||
func body(content: Content) -> some View {
|
||||
let preparing = animate && !entered && !reduceMotion
|
||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentScrollMetrics: Equatable {
|
||||
var offset: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
var offset: CGFloat
|
||||
var expectedCount: Int? = nil
|
||||
}
|
||||
private struct AgentRowFrames: PreferenceKey {
|
||||
static let defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
var isForking: Bool = false
|
||||
var onForkResponse: ((String) -> Void)? = nil
|
||||
|
||||
@State private var hasTrackedToolCallMessages = false
|
||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||
@@ -45,15 +41,7 @@ struct SybilChatTranscriptView: View {
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
@@ -70,8 +58,7 @@ struct SybilChatTranscriptView: View {
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
}
|
||||
@@ -79,6 +66,7 @@ struct SybilChatTranscriptView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
syncKnownToolCallMessageIDs()
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
}
|
||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||
syncKnownToolCallMessageIDs()
|
||||
@@ -149,13 +137,9 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
||||
return items
|
||||
}
|
||||
|
||||
struct MessageBubble: View {
|
||||
private struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
var agentStyle = false
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -165,46 +149,13 @@ struct MessageBubble: View {
|
||||
message.role == .user
|
||||
}
|
||||
|
||||
private var isStreamingAssistant: Bool {
|
||||
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
private var canFork: Bool {
|
||||
message.role == .assistant &&
|
||||
!message.id.hasPrefix("temp-") &&
|
||||
onFork != nil
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if canFork {
|
||||
bubbleContent
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = message.content
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
onFork?()
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(isForking)
|
||||
}
|
||||
} else {
|
||||
bubbleContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bubbleContent: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
@@ -223,26 +174,25 @@ struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isStreamingAssistant {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
.textSelection(.enabled)
|
||||
.background(
|
||||
Group {
|
||||
@@ -253,10 +203,6 @@ struct MessageBubble: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
} else if agentStyle {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(Color.clear)
|
||||
@@ -286,123 +232,6 @@ struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantContentView: View {
|
||||
private enum Phase: Hashable {
|
||||
case pending
|
||||
case trace(Int)
|
||||
case answer
|
||||
}
|
||||
|
||||
var content: String
|
||||
var isSending: Bool
|
||||
var presentation: StreamingAssistantPresentation?
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var retainedTraceHeight: CGFloat = 0
|
||||
|
||||
private var traceText: String? {
|
||||
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trace.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
private var hasAnswer: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var phase: Phase {
|
||||
if hasAnswer {
|
||||
return .answer
|
||||
}
|
||||
if traceText != nil {
|
||||
return .trace(presentation?.traceRevision ?? 0)
|
||||
}
|
||||
return .pending
|
||||
}
|
||||
|
||||
private var traceTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .asymmetric(
|
||||
insertion: .opacity.combined(with: .offset(y: 5)),
|
||||
removal: .opacity.combined(with: .offset(y: -5))
|
||||
)
|
||||
}
|
||||
|
||||
private var answerTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .opacity.combined(with: .offset(y: 3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let traceText {
|
||||
Text(traceText)
|
||||
.font(.sybil(size: 15))
|
||||
.italic()
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.opacity(0.78)
|
||||
.textSelection(.disabled)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityLabel("Assistant is working")
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: StreamingTraceHeightPreferenceKey.self,
|
||||
value: geometry.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
|
||||
.transition(traceTransition)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.transition(answerTransition)
|
||||
.zIndex(2)
|
||||
}
|
||||
|
||||
if isSending && !hasAnswer && traceText == nil {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.transition(reduceMotion ? .identity : .opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
|
||||
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
|
||||
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
|
||||
retainedTraceHeight = measuredHeight
|
||||
}
|
||||
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
@@ -611,7 +440,7 @@ private struct ToolCallStackCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallActivityChip: View {
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// This Foundation-only contract is compiled into both the Catalyst client and the
|
||||
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelBridging)
|
||||
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
||||
init()
|
||||
var isVisible: Bool { get }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
||||
func updateState(_ data: Data)
|
||||
func show()
|
||||
func hide()
|
||||
func activateForQuickQuestion()
|
||||
func activateMainWindow()
|
||||
func stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelDelegate)
|
||||
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
||||
func quickQuestionToggleRequested()
|
||||
func quickQuestionPromptChanged(_ prompt: String)
|
||||
func quickQuestionSubmitRequested()
|
||||
func quickQuestionProviderChanged(_ provider: String)
|
||||
func quickQuestionModelChanged(_ model: String)
|
||||
func quickQuestionConvertRequested()
|
||||
func quickQuestionOpenAppRequested()
|
||||
func quickQuestionPanelDismissed()
|
||||
}
|
||||
|
||||
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
||||
struct ProviderOption: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
}
|
||||
|
||||
var prompt = ""
|
||||
var answer = ""
|
||||
var toolSummaries: [String] = []
|
||||
var provider = ""
|
||||
var providers: [ProviderOption] = []
|
||||
var model = ""
|
||||
var models: [String] = []
|
||||
var isSending = false
|
||||
var isConverting = false
|
||||
var canSend = false
|
||||
var canConvert = false
|
||||
var isAuthenticated = false
|
||||
var isCheckingSession = true
|
||||
var error: String?
|
||||
|
||||
var isBusy: Bool { isSending || isConverting }
|
||||
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
||||
}
|
||||
|
||||
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
||||
// per press/release pair, without suppressing a second deliberate press.
|
||||
struct SybilQuickQuestionHotKeyState {
|
||||
private var isPressed = false
|
||||
|
||||
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
||||
if !isKeyDown {
|
||||
isPressed = false
|
||||
return false
|
||||
}
|
||||
guard !isPressed else { return false }
|
||||
isPressed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
||||
public static let shared = SybilMacQuickQuestionController()
|
||||
|
||||
private var pluginBundle: Bundle?
|
||||
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
||||
private var viewModel: SybilViewModel?
|
||||
private var observationGeneration = 0
|
||||
private var isPresentationRequested = false
|
||||
private var activationRequested = false
|
||||
private var presentationTask: Task<Void, Never>?
|
||||
private var foregroundObservation: AnyCancellable?
|
||||
private var notificationCenter = NotificationCenter.default
|
||||
private var isApplicationInForeground: @MainActor () -> Bool = {
|
||||
UIApplication.shared.applicationState != .background
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// Inject foreground readiness as well as the panel so tests can exercise
|
||||
// windowless activation without activating apps or showing real windows.
|
||||
init(
|
||||
bridge: any SybilQuickQuestionPanelBridging,
|
||||
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
||||
notificationCenter: NotificationCenter = NotificationCenter()
|
||||
) {
|
||||
self.bridge = bridge
|
||||
self.isApplicationInForeground = isApplicationInForeground
|
||||
self.notificationCenter = notificationCenter
|
||||
super.init()
|
||||
_ = bridge.start(delegate: self)
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
guard bridge == nil else { return }
|
||||
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
||||
let bundle = Bundle(url: url)
|
||||
else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try bundle.loadAndReturnError()
|
||||
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
||||
return
|
||||
}
|
||||
let plugin = pluginClass.init()
|
||||
pluginBundle = bundle
|
||||
bridge = plugin
|
||||
let status = plugin.start(delegate: self)
|
||||
if status != 0 {
|
||||
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ viewModel: SybilViewModel) {
|
||||
guard self.viewModel !== viewModel else { return }
|
||||
// A window becoming active must not replace an in-flight quick question.
|
||||
if self.viewModel != nil,
|
||||
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
||||
return
|
||||
}
|
||||
self.viewModel = viewModel
|
||||
if bridge?.isVisible == true { updateAndObserve() }
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
start()
|
||||
if isPresentationRequested || bridge?.isVisible == true {
|
||||
cancelPresentation()
|
||||
bridge?.hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
start()
|
||||
isPresentationRequested = true
|
||||
schedulePresentationIfNeeded()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
cancelPresentation()
|
||||
foregroundObservation = nil
|
||||
viewModel?.cancelQuickQuestion()
|
||||
bridge?.stop()
|
||||
bridge = nil
|
||||
// Keep the loaded bundle alive for the lifetime of its code.
|
||||
}
|
||||
|
||||
private func observeForegroundTransitionsIfNeeded() {
|
||||
guard foregroundObservation == nil else { return }
|
||||
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.schedulePresentationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePresentationIfNeeded() {
|
||||
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
||||
// Leave the Carbon/NSApplication event callback before touching windows.
|
||||
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
||||
// is not a substitute for checking UIKit's foreground lifecycle state.
|
||||
presentationTask = Task { @MainActor [weak self] in
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.presentationTask = nil
|
||||
guard self.isPresentationRequested else { return }
|
||||
guard self.isApplicationInForeground() else {
|
||||
guard !self.activationRequested else { return }
|
||||
self.activationRequested = true
|
||||
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
||||
self.bridge?.activateForQuickQuestion()
|
||||
// Also handle an activation that completes synchronously. If it
|
||||
// has not completed, subsequent UIKit notifications resume us.
|
||||
self.schedulePresentationIfNeeded()
|
||||
return
|
||||
}
|
||||
self.activationRequested = false
|
||||
self.updateAndObserve()
|
||||
self.bridge?.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPresentation() {
|
||||
isPresentationRequested = false
|
||||
activationRequested = false
|
||||
presentationTask?.cancel()
|
||||
presentationTask = nil
|
||||
observationGeneration += 1
|
||||
}
|
||||
|
||||
private func updateAndObserve() {
|
||||
guard let bridge else { return }
|
||||
observationGeneration += 1
|
||||
let generation = observationGeneration
|
||||
let state = withObservationTracking {
|
||||
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
do {
|
||||
bridge.updateState(try JSONEncoder().encode(state))
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionToggleRequested() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
func quickQuestionPromptChanged(_ prompt: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.updateQuickQuestionPrompt(prompt)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionSubmitRequested() {
|
||||
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
||||
viewModel.sendQuickQuestion()
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionProviderChanged(_ provider: String) {
|
||||
guard let viewModel, let provider = Provider(rawValue: provider),
|
||||
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionProvider(provider)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionModelChanged(_ model: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionModel(model)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionConvertRequested() {
|
||||
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await viewModel.convertQuickQuestionToChat() {
|
||||
self.cancelPresentation()
|
||||
self.openMainWindow()
|
||||
self.bridge?.hide()
|
||||
} else {
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionOpenAppRequested() {
|
||||
cancelPresentation()
|
||||
openMainWindow()
|
||||
bridge?.hide()
|
||||
}
|
||||
|
||||
private func openMainWindow() {
|
||||
let session = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.session
|
||||
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
||||
bridge?.activateMainWindow()
|
||||
}
|
||||
|
||||
func quickQuestionPanelDismissed() {
|
||||
// Toggling out preserves the draft and lets an answer finish. Reopening
|
||||
// reads the latest state, without starting a new request.
|
||||
cancelPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
extension SybilViewModel {
|
||||
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
||||
SybilQuickQuestionPanelState(
|
||||
prompt: quickQuestionPrompt,
|
||||
answer: quickQuestionAnswerText,
|
||||
toolSummaries: quickQuestionMessages.compactMap { message in
|
||||
guard let metadata = message.toolCallMetadata else { return nil }
|
||||
return metadata.summary ?? message.content
|
||||
},
|
||||
provider: quickQuestionProvider.rawValue,
|
||||
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
||||
model: quickQuestionModel,
|
||||
models: quickQuestionProviderModelOptions,
|
||||
isSending: isQuickQuestionSending,
|
||||
isConverting: isConvertingQuickQuestion,
|
||||
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
||||
canConvert: canConvertQuickQuestion,
|
||||
isAuthenticated: isAuthenticated,
|
||||
isCheckingSession: isCheckingSession,
|
||||
error: quickQuestionError
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,37 +0,0 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
|
||||
// Scene lifetime is independent of focused commands: a connected window may
|
||||
// still be authenticating, inactive, or minimized. The native Quick Question
|
||||
// panel is not a workspace scene and does not count as an open window here.
|
||||
@MainActor
|
||||
public final class SybilMacWindowCommands: ObservableObject {
|
||||
public static let shared = SybilMacWindowCommands()
|
||||
|
||||
@Published private var connectedWindowIDs: Set<String> = []
|
||||
|
||||
init() {}
|
||||
|
||||
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
||||
|
||||
public func windowConnected(sessionID: String) {
|
||||
connectedWindowIDs.insert(sessionID)
|
||||
}
|
||||
|
||||
public func windowDisconnected(sessionID: String) {
|
||||
connectedWindowIDs.remove(sessionID)
|
||||
}
|
||||
|
||||
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
||||
!hasOpenWindows || hasFocusedActions
|
||||
}
|
||||
|
||||
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
||||
if hasOpenWindows {
|
||||
newChat?()
|
||||
} else {
|
||||
openWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,7 +4,6 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case openai
|
||||
case anthropic
|
||||
case xai
|
||||
case gemini
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
@@ -12,7 +11,6 @@ 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"
|
||||
}
|
||||
}
|
||||
@@ -154,8 +152,6 @@ 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
|
||||
@@ -166,39 +162,6 @@ 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?
|
||||
@@ -219,9 +182,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var searchSnippet: String? = nil
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -236,8 +196,6 @@ 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
|
||||
@@ -253,8 +211,6 @@ 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
|
||||
@@ -270,8 +226,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
return ChatSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
parentChatId: parentChatId,
|
||||
titleGenerationPending: titleGenerationPending,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
@@ -297,45 +251,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkspaceItem {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case id
|
||||
case title
|
||||
case query
|
||||
case searchSnippet
|
||||
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)
|
||||
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
|
||||
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
|
||||
@@ -359,14 +274,9 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
toolName: object["toolName"]?.stringValue ?? name,
|
||||
status: object["status"]?.stringValue,
|
||||
summary: object["summary"]?.stringValue,
|
||||
durationMs: object["durationMs"]?.numberValue.flatMap { value in
|
||||
value >= 0 && value < Double(Int.max) ? Int(value) : nil
|
||||
},
|
||||
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||
error: object["error"]?.stringValue,
|
||||
resultPreview: object["resultPreview"]?.stringValue,
|
||||
args: object["args"],
|
||||
result: object["result"],
|
||||
startedAt: object["startedAt"]?.stringValue
|
||||
resultPreview: object["resultPreview"]?.stringValue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -383,9 +293,6 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
public var args: JSONValue? = nil
|
||||
public var result: JSONValue? = nil
|
||||
public var startedAt: String? = nil
|
||||
}
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
@@ -482,8 +389,6 @@ 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
|
||||
@@ -495,41 +400,6 @@ 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
|
||||
@@ -741,11 +611,6 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceSearchResponse: Codable, Sendable {
|
||||
var items: [WorkspaceItem]
|
||||
var hasMore: Bool
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
enum PhoneRoute: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case draftChat
|
||||
@@ -15,7 +14,6 @@ enum PhoneRoute: Hashable {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .settings:
|
||||
return .settings
|
||||
}
|
||||
@@ -66,7 +64,6 @@ struct SybilPhoneShellView: View {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
@@ -90,10 +87,6 @@ struct SybilPhoneShellView: View {
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
@@ -137,10 +130,7 @@ struct SybilPhoneShellView: View {
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay,
|
||||
onOpenForkedChat: { chatID in
|
||||
showRoute(.chat(chatID))
|
||||
}
|
||||
onShowSidebar: showSidebarOverlay
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
@@ -249,7 +239,6 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||
dismissSidebarKeyboard()
|
||||
let update = {
|
||||
route = nextRoute
|
||||
}
|
||||
@@ -279,19 +268,12 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func hideSidebarOverlay() {
|
||||
dismissSidebarKeyboard()
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func dismissSidebarKeyboard() {
|
||||
if isSidebarOverlayPresented {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||
return
|
||||
@@ -529,12 +511,7 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
},
|
||||
onSelectAgent: { onSelect(.agent) },
|
||||
isAgentSelected: highlightedSelection == .agent
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -608,7 +585,6 @@ private struct SybilPhoneDestinationView: View {
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
let onOpenForkedChat: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
@@ -617,8 +593,7 @@ private struct SybilPhoneDestinationView: View {
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat,
|
||||
onOpenForkedChat: onOpenForkedChat
|
||||
onRequestNewChat: onRequestNewChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task(id: route) {
|
||||
@@ -638,8 +613,6 @@ private struct SybilPhoneDestinationView: View {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .agent:
|
||||
viewModel.select(.agent)
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
case .draftSearch:
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SybilPromptField: View {
|
||||
var placeholder: String = "Enter Prompt"
|
||||
@Binding var text: String
|
||||
@FocusState.Binding var focused: Bool
|
||||
var selection: Binding<TextSelection?>? = nil
|
||||
var axis: Axis = .vertical
|
||||
var submit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let selection {
|
||||
TextField(placeholder, text: $text, selection: selection, axis: axis)
|
||||
} else {
|
||||
TextField(placeholder, text: $text, axis: axis)
|
||||
}
|
||||
}
|
||||
.focused($focused)
|
||||
.font(.sybil(.body))
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submit)
|
||||
.submitOnMacReturn(submit)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilPromptSendButton: View {
|
||||
var systemImage: String = "arrow.up"
|
||||
var canSend: Bool
|
||||
var submit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: submit) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
canSend
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSend)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ struct SybilQuickQuestionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var promptSelection: TextSelection?
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
@@ -37,8 +36,6 @@ struct SybilQuickQuestionView: View {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
let prompt = viewModel.quickQuestionPrompt
|
||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +59,7 @@ struct SybilQuickQuestionView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if hasAnswerContent {
|
||||
ForEach(viewModel.quickQuestionMessages) { message in
|
||||
QuickQuestionMessageView(
|
||||
message: message,
|
||||
isSending: viewModel.isQuickQuestionSending,
|
||||
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
|
||||
)
|
||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
if let error = viewModel.quickQuestionError {
|
||||
@@ -95,19 +88,46 @@ struct SybilQuickQuestionView: View {
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
placeholder: "Ask anything...",
|
||||
TextField(
|
||||
"Ask anything...",
|
||||
text: Binding(
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
focused: $promptFocused,
|
||||
selection: $promptSelection,
|
||||
axis: .horizontal,
|
||||
submit: submitQuestion
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($promptFocused)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submitQuestion)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: submitQuestion)
|
||||
Button(action: submitQuestion) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendQuickQuestion
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendQuickQuestion)
|
||||
.accessibilityLabel("Ask quick question")
|
||||
}
|
||||
|
||||
@@ -239,11 +259,11 @@ private struct QuickQuestionPickerPill: View {
|
||||
private struct QuickQuestionMessageView: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -252,12 +272,15 @@ private struct QuickQuestionMessageView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.font(.body)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// A vertical TextField treats Return as editing, not form submission. Handle
|
||||
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
||||
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
||||
@MainActor
|
||||
enum SybilReturnKeySubmission {
|
||||
static func handle(
|
||||
phase: KeyPress.Phases,
|
||||
modifiers: EventModifiers,
|
||||
submit: () -> Void
|
||||
) -> KeyPress.Result {
|
||||
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
||||
return .ignored
|
||||
}
|
||||
if phase == .down {
|
||||
submit()
|
||||
}
|
||||
// Consume autorepeat without submitting or inserting a newline.
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
||||
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,7 @@ struct SybilSearchResultsView: View {
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 20 + topContentInset)
|
||||
.padding(.bottom, 20 + bottomContentInset)
|
||||
|
||||
@@ -11,13 +11,11 @@ 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"
|
||||
}
|
||||
|
||||
@@ -46,18 +44,16 @@ 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
|
||||
|
||||
self.quickQuestionPreferredProvider =
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||
self.quickQuestionPreferredModelByProvider = [
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
|
||||
.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"
|
||||
]
|
||||
}
|
||||
@@ -76,14 +72,12 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSidebarView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -112,12 +111,8 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
var onSelectAgent: (() -> Void)? = nil
|
||||
var isAgentSelected: Bool? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
@@ -131,122 +126,37 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
AgentAvatar()
|
||||
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
|
||||
if viewModel.agent.unreadCount > 0 {
|
||||
Text(String(viewModel.agent.unreadCount))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(SybilTheme.primary, in: Capsule())
|
||||
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(12)
|
||||
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("agent-sidebar")
|
||||
.padding(.horizontal, 10).padding(.top, 10)
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
TextField("Search chats and web searches", text: Binding(
|
||||
get: { viewModel.sidebarSearchQuery },
|
||||
set: { viewModel.setSidebarSearchQuery($0) }
|
||||
))
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.focused($isSearchFocused)
|
||||
.accessibilityLabel("Search conversation history and saved web searches")
|
||||
.onSubmit {
|
||||
isSearchFocused = false
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
|
||||
if !viewModel.sidebarSearchQuery.isEmpty {
|
||||
Button {
|
||||
viewModel.setSidebarSearchQuery("")
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear history search")
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
|
||||
.padding(10)
|
||||
|
||||
itemList
|
||||
}
|
||||
}
|
||||
|
||||
private var itemList: some View {
|
||||
Group {
|
||||
if viewModel.isSearchingSidebar ||
|
||||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
|
||||
VStack(spacing: 12) {
|
||||
Text(error)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Try again") {
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else if viewModel.visibleSidebarItems.isEmpty {
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if viewModel.isSidebarSearchActive {
|
||||
Text("Try a title, a query, or words from a conversation.")
|
||||
.font(.sybil(.caption))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.visibleSidebarItems) { item in
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
isSearchFocused = false
|
||||
onSelect(item)
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
@@ -258,26 +168,13 @@ struct SybilSidebarItemList: View {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if case let .chat(chatID) = item.selection {
|
||||
Button {
|
||||
forkChat(item)
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = chatID
|
||||
} label: {
|
||||
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
@@ -289,16 +186,9 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
|
||||
Text("Showing the 100 most recent matches. Refine your search to find more.")
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
@@ -325,52 +215,11 @@ struct SybilSidebarItemList: View {
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func forkChat(_ item: SidebarItem) {
|
||||
guard case let .chat(chatID) = item.selection else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
let draftKindAtStart = viewModel.draftKind
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == draftKindAtStart else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
var isSelected: Bool
|
||||
var searchQuery = ""
|
||||
|
||||
private func highlightedSnippet(_ snippet: String) -> AttributedString {
|
||||
var highlighted = AttributedString(snippet)
|
||||
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
|
||||
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
|
||||
guard let range = Range(match.range, in: searchQuery) else { return nil }
|
||||
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
}.filter { !$0.isEmpty }
|
||||
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
|
||||
guard let range = Range(word.range, in: snippet) else { continue }
|
||||
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
guard terms.contains(where: { normalized.hasPrefix($0) }),
|
||||
let start = AttributedString.Index(range.lowerBound, within: highlighted),
|
||||
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
|
||||
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
|
||||
}
|
||||
return highlighted
|
||||
}
|
||||
|
||||
private var isHighlighted: Bool {
|
||||
isSelected
|
||||
@@ -432,15 +281,6 @@ struct SybilSidebarRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
if let snippet = item.searchSnippet, !snippet.isEmpty {
|
||||
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -64,10 +64,6 @@ extension Font {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilLayout {
|
||||
static let webContentMaxWidth: CGFloat = 896
|
||||
}
|
||||
|
||||
enum SybilTheme {
|
||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||
|
||||
@@ -7,7 +7,6 @@ enum DraftKind {
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case settings
|
||||
@@ -18,8 +17,6 @@ enum SidebarSelection: Hashable {
|
||||
return "chat:\(chatID)"
|
||||
case let .search(searchID):
|
||||
return "search:\(searchID)"
|
||||
case .agent:
|
||||
return "agent"
|
||||
case .settings:
|
||||
return "settings"
|
||||
}
|
||||
@@ -41,75 +38,11 @@ struct SidebarItem: Identifiable, Hashable {
|
||||
var starredAt: Date?
|
||||
var initiatedLabel: String?
|
||||
var isRunning: Bool
|
||||
var searchSnippet: String? = nil
|
||||
}
|
||||
|
||||
enum StreamingAssistantContentUpdate: Equatable, Sendable {
|
||||
case unchanged
|
||||
case replace(String)
|
||||
}
|
||||
|
||||
struct StreamingAssistantState: Equatable, Sendable {
|
||||
private(set) var segmentText = ""
|
||||
private(set) var initiatedToolCallIDs: Set<String> = []
|
||||
private(set) var ephemeralTrace: String?
|
||||
private(set) var traceRevision = 0
|
||||
|
||||
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
guard !text.isEmpty else { return .unchanged }
|
||||
segmentText += text
|
||||
ephemeralTrace = nil
|
||||
return .replace(segmentText)
|
||||
}
|
||||
|
||||
mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate {
|
||||
guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else {
|
||||
return .unchanged
|
||||
}
|
||||
|
||||
let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
segmentText = ""
|
||||
if !trace.isEmpty {
|
||||
ephemeralTrace = trace
|
||||
traceRevision += 1
|
||||
}
|
||||
return .replace("")
|
||||
}
|
||||
|
||||
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .replace(text)
|
||||
}
|
||||
|
||||
mutating func receiveError() -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .unchanged
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantPresentation: Equatable, Sendable {
|
||||
var messageID: String
|
||||
var ephemeralTrace: String?
|
||||
var traceRevision: Int
|
||||
}
|
||||
|
||||
private struct PendingChatState {
|
||||
var chatID: String?
|
||||
var messages: [Message]
|
||||
var streamingAssistantState = StreamingAssistantState()
|
||||
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: streamingAssistantState.ephemeralTrace,
|
||||
traceRevision: streamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ActiveSendContext: Hashable {
|
||||
@@ -156,7 +89,6 @@ private actor SearchStreamStatus {
|
||||
@Observable
|
||||
final class SybilViewModel {
|
||||
let settings: SybilSettingsStore
|
||||
let agent = SybilAgentRoom()
|
||||
|
||||
var isCheckingSession = true
|
||||
var isAuthenticated = false
|
||||
@@ -166,15 +98,6 @@ final class SybilViewModel {
|
||||
var chats: [ChatSummary] = []
|
||||
var searches: [SearchSummary] = []
|
||||
var workspaceItems: [WorkspaceItem] = []
|
||||
private(set) var sidebarSearchQuery = ""
|
||||
private(set) var sidebarSearchResults: [WorkspaceItem] = []
|
||||
private(set) var isSearchingSidebar = false
|
||||
private(set) var sidebarSearchError: String?
|
||||
private(set) var sidebarSearchHasMore = false
|
||||
@ObservationIgnored
|
||||
private var sidebarSearchTask: Task<Void, Never>?
|
||||
@ObservationIgnored
|
||||
private var sidebarSearchRequestID = UUID()
|
||||
|
||||
var selectedItem: SidebarSelection?
|
||||
var selectedChat: ChatDetail?
|
||||
@@ -184,7 +107,6 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var isForkingChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -203,7 +125,6 @@ final class SybilViewModel {
|
||||
var quickQuestionSubmittedModel: String?
|
||||
var isQuickQuestionSending = false
|
||||
var isConvertingQuickQuestion = false
|
||||
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
|
||||
@ObservationIgnored
|
||||
private var hasBootstrapped = false
|
||||
@@ -236,10 +157,9 @@ final class SybilViewModel {
|
||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||
|
||||
private let fallbackModels: [Provider: [String]] = [
|
||||
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||
.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"]
|
||||
]
|
||||
|
||||
@@ -343,8 +263,6 @@ final class SybilViewModel {
|
||||
}
|
||||
return "Search"
|
||||
|
||||
case .agent:
|
||||
return "Sybil Agent"
|
||||
case .settings:
|
||||
return "Settings"
|
||||
}
|
||||
@@ -384,7 +302,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var showsComposer: Bool {
|
||||
if selectedItem == .agent { return false }
|
||||
if case .settings = selectedItem {
|
||||
return false
|
||||
}
|
||||
@@ -442,30 +359,6 @@ final class SybilViewModel {
|
||||
return canonical
|
||||
}
|
||||
|
||||
var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
if case let .chat(chatID) = selectedItem,
|
||||
let pending = pendingChatStates[chatID] {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
if draftKind == .chat, let pending = pendingDraftChatState {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
|
||||
traceRevision: quickQuestionStreamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
|
||||
var displayedSearch: SearchDetail? {
|
||||
if case let .search(searchID) = selectedItem,
|
||||
let activeSearch = activeSearchDetails[searchID] {
|
||||
@@ -499,19 +392,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var sidebarItems: [SidebarItem] {
|
||||
makeSidebarItems(workspaceItems)
|
||||
}
|
||||
|
||||
var isSidebarSearchActive: Bool {
|
||||
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var visibleSidebarItems: [SidebarItem] {
|
||||
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
|
||||
}
|
||||
|
||||
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
|
||||
items.map { item in
|
||||
workspaceItems.map { item in
|
||||
switch item.type {
|
||||
case .chat:
|
||||
let initiatedLabel: String?
|
||||
@@ -524,21 +405,16 @@ final class SybilViewModel {
|
||||
} else {
|
||||
initiatedLabel = nil
|
||||
}
|
||||
let starOwner = item.parentChatId.flatMap { parentChatID in
|
||||
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
|
||||
sidebarSearchResults.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: starOwner.starred,
|
||||
starredAt: starOwner.starredAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(item.id),
|
||||
searchSnippet: item.searchSnippet
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
)
|
||||
|
||||
case .search:
|
||||
@@ -550,70 +426,20 @@ final class SybilViewModel {
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(item.id),
|
||||
searchSnippet: item.searchSnippet
|
||||
isRunning: isSearchRowRunning(item.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setSidebarSearchQuery(_ query: String) {
|
||||
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
sidebarSearchQuery = query
|
||||
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
|
||||
scheduleSidebarSearch()
|
||||
}
|
||||
|
||||
func refreshSidebarSearch() async {
|
||||
guard isSidebarSearchActive else { return }
|
||||
scheduleSidebarSearch(debounce: false)
|
||||
await sidebarSearchTask?.value
|
||||
}
|
||||
|
||||
private func scheduleSidebarSearch(debounce: Bool = true) {
|
||||
sidebarSearchTask?.cancel()
|
||||
let requestID = UUID()
|
||||
sidebarSearchRequestID = requestID
|
||||
sidebarSearchResults = []
|
||||
sidebarSearchError = nil
|
||||
sidebarSearchHasMore = false
|
||||
isSearchingSidebar = false
|
||||
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty, isAuthenticated else { return }
|
||||
guard query.utf16.count <= 200 else {
|
||||
sidebarSearchError = "Use 200 characters or fewer."
|
||||
return
|
||||
}
|
||||
isSearchingSidebar = true
|
||||
sidebarSearchTask = Task { [weak self] in
|
||||
do {
|
||||
if debounce { try await Task.sleep(for: .milliseconds(300)) }
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let response = try await self.client().searchWorkspaceItems(query: query)
|
||||
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||
self.sidebarSearchResults = response.items
|
||||
self.sidebarSearchHasMore = response.hasMore
|
||||
self.isSearchingSidebar = false
|
||||
} catch {
|
||||
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||
self.isSearchingSidebar = false
|
||||
if !self.isCancellation(error) {
|
||||
self.sidebarSearchError = self.normalizeAPIError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedChatSummary: ChatSummary? {
|
||||
guard case let .chat(chatID) = selectedItem else { return nil }
|
||||
return chats.first(where: { $0.id == chatID }) ??
|
||||
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
|
||||
return chats.first(where: { $0.id == chatID })
|
||||
}
|
||||
|
||||
var selectedSearchSummary: SearchSummary? {
|
||||
guard case let .search(searchID) = selectedItem else { return nil }
|
||||
return searches.first(where: { $0.id == searchID }) ??
|
||||
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
|
||||
return searches.first(where: { $0.id == searchID })
|
||||
}
|
||||
|
||||
var hasRefreshableSelection: Bool {
|
||||
@@ -624,7 +450,7 @@ final class SybilViewModel {
|
||||
switch selectedItem {
|
||||
case .chat, .search:
|
||||
return true
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -640,8 +466,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func reconnect() async {
|
||||
agent.disconnect()
|
||||
setSidebarSearchQuery("")
|
||||
isCheckingSession = true
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
@@ -679,10 +503,6 @@ final class SybilViewModel {
|
||||
)
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
} catch {
|
||||
stopActiveRunPolling()
|
||||
@@ -796,7 +616,6 @@ final class SybilViewModel {
|
||||
quickQuestionTask = nil
|
||||
quickQuestionRunID = nil
|
||||
isQuickQuestionSending = false
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -813,12 +632,9 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
cancelQuickQuestion()
|
||||
// Lock submission before the task starts, including repeated Return
|
||||
// events delivered in the same main-actor turn.
|
||||
isQuickQuestionSending = true
|
||||
let selectedProvider = quickQuestionProvider
|
||||
let task = Task { [weak self] in
|
||||
guard let self, !Task.isCancelled else {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||
@@ -870,8 +686,6 @@ 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,
|
||||
@@ -952,15 +766,6 @@ final class SybilViewModel {
|
||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||
|
||||
// Search can open history outside the recent collection. Retain its
|
||||
// summary for provider settings, starring, and subsequent refreshes.
|
||||
if let item = sidebarSearchResults.first(where: {
|
||||
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
|
||||
}) {
|
||||
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
|
||||
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
|
||||
}
|
||||
|
||||
if draftKind == nil, selectedItem == selection {
|
||||
errorMessage = nil
|
||||
if case .search = selection {
|
||||
@@ -993,7 +798,7 @@ final class SybilViewModel {
|
||||
selectedChat = nil
|
||||
composerAttachments = []
|
||||
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
return nil
|
||||
@@ -1011,7 +816,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func selectAdjacentSidebarItem(offset: Int) {
|
||||
let items = visibleSidebarItems
|
||||
let items = sidebarItems
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
@@ -1031,7 +836,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func deleteItem(_ selection: SidebarSelection) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1044,11 +848,10 @@ final class SybilViewModel {
|
||||
try await client.deleteChat(chatID: chatID)
|
||||
case let .search(searchID):
|
||||
try await client.deleteSearch(searchID: searchID)
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
await refreshCollections(preferredSelection: nil)
|
||||
await refreshSidebarSearch()
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
||||
@@ -1057,36 +860,6 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
|
||||
guard isAuthenticated, !isForkingChat else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard messageID != nil || !isChatRowRunning(chatID) else {
|
||||
errorMessage = "Wait for the response to finish before forking this chat."
|
||||
return nil
|
||||
}
|
||||
|
||||
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
|
||||
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
|
||||
errorMessage = nil
|
||||
isForkingChat = true
|
||||
defer {
|
||||
isForkingChat = false
|
||||
}
|
||||
|
||||
do {
|
||||
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
|
||||
applyChatSummary(forkedChat, moveToFront: true)
|
||||
return forkedChat
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func renameChat(chatID: String, title: String) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
@@ -1104,7 +877,6 @@ final class SybilViewModel {
|
||||
do {
|
||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||
applyChatSummary(updated, moveToFront: true)
|
||||
await refreshSidebarSearch()
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||
@@ -1112,7 +884,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1124,13 +895,12 @@ final class SybilViewModel {
|
||||
let client = try client()
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
let rootChatID = chatFamilyRootID(for: chatID)
|
||||
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||
applyChatSummary(updated, moveToFront: false)
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
applySearchSummary(updated, moveToFront: false)
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
@@ -1153,14 +923,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
isAppActive = false
|
||||
agent.setActive(false)
|
||||
appLifecycleGeneration += 1
|
||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
isAppActive = true
|
||||
agent.setActive(true)
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
@@ -1207,10 +975,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||
if isSidebarSearchActive {
|
||||
await refreshSidebarSearch()
|
||||
return
|
||||
}
|
||||
guard isAuthenticated, !isCheckingSession else {
|
||||
return
|
||||
}
|
||||
@@ -1366,7 +1130,6 @@ final class SybilViewModel {
|
||||
quickQuestionSubmittedPrompt = prompt
|
||||
quickQuestionSubmittedProvider = provider
|
||||
quickQuestionSubmittedModel = model
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
quickQuestionMessages = [
|
||||
Message(
|
||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||
@@ -1389,11 +1152,13 @@ final class SybilViewModel {
|
||||
let streamStatus = CompletionStreamStatus()
|
||||
|
||||
do {
|
||||
try await client().runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest(
|
||||
try await client().runCompletionStream(
|
||||
body: CompletionStreamRequest(
|
||||
chatId: nil,
|
||||
persist: false,
|
||||
provider: provider,
|
||||
model: model,
|
||||
question: prompt
|
||||
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
||||
)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
@@ -1411,8 +1176,6 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
quickQuestionError = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||
}
|
||||
@@ -1427,16 +1190,17 @@ final class SybilViewModel {
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutateQuickQuestionAssistantMessage { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
|
||||
case let .done(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
mutateQuickQuestionAssistantMessage { _ in
|
||||
payload.text
|
||||
}
|
||||
|
||||
case let .error(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -1488,7 +1252,7 @@ final class SybilViewModel {
|
||||
|
||||
if let nextSelection {
|
||||
switch nextSelection {
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
case .chat, .search:
|
||||
@@ -1551,15 +1315,7 @@ final class SybilViewModel {
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
|
||||
var items = nextWorkspaceItems
|
||||
// A selected historical search result may be outside the latest 100.
|
||||
if let preferredSelection,
|
||||
let selectedSummary = workspaceItems.first(where: {
|
||||
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
|
||||
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
|
||||
items.append(selectedSummary)
|
||||
}
|
||||
applyWorkspaceItems(items)
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
@@ -1697,8 +1453,6 @@ 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
|
||||
@@ -1737,11 +1491,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
||||
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||
var updated = item
|
||||
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
|
||||
sidebarSearchResults[index] = updated
|
||||
}
|
||||
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||
workspaceItems.remove(at: existingIndex)
|
||||
if moveToFront {
|
||||
@@ -1755,22 +1504,6 @@ 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
|
||||
}
|
||||
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
return chatID
|
||||
}
|
||||
|
||||
private func attachToVisibleActiveRunIfNeeded() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1799,7 +1532,7 @@ final class SybilViewModel {
|
||||
await self?.attachToActiveSearchStream(searchID: searchID)
|
||||
}
|
||||
|
||||
case .settings, .agent, nil:
|
||||
case .settings, nil:
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1851,28 +1584,21 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
pendingChatStates[chatID] = nil
|
||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
selectedChat = try await client.getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
}
|
||||
pendingChatStates[chatID] = nil
|
||||
} catch {
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
defer {
|
||||
pendingChatStates[chatID] = nil
|
||||
}
|
||||
pendingChatStates[chatID] = nil
|
||||
|
||||
if isCancellation(error) {
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
|
||||
if isActiveStreamNotFound(error) {
|
||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
@@ -1886,11 +1612,8 @@ final class SybilViewModel {
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
do {
|
||||
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
selectedChat = try await client().getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
} catch {
|
||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||
}
|
||||
@@ -2008,13 +1731,12 @@ final class SybilViewModel {
|
||||
return selectedChat?.id != chatID
|
||||
case let .search(searchID):
|
||||
return selectedSearch?.id != searchID
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSelectionIfNeeded() async {
|
||||
if selectedItem == .agent { isLoadingSelection = false; return }
|
||||
guard let target = selectedItem else {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -2029,16 +1751,13 @@ 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
|
||||
if isSelectingDifferentChat {
|
||||
requestChatBottomPin()
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
@@ -2056,7 +1775,7 @@ final class SybilViewModel {
|
||||
selectedSearch = search
|
||||
selectedChat = nil
|
||||
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
errorMessage = nil
|
||||
@@ -2111,13 +1830,6 @@ final class SybilViewModel {
|
||||
} else {
|
||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||
}
|
||||
defer {
|
||||
if let resolvedChatID = chatID {
|
||||
pendingChatStates[resolvedChatID] = nil
|
||||
} else {
|
||||
clearPendingChatState(for: sendContext)
|
||||
}
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if chatID == nil {
|
||||
@@ -2138,8 +1850,6 @@ 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,
|
||||
@@ -2198,7 +1908,7 @@ final class SybilViewModel {
|
||||
let streamLifecycleGeneration = appLifecycleGeneration
|
||||
let streamStartedWhileInactive = !isAppActive
|
||||
|
||||
if shouldRequestChatTitle(baseChat) {
|
||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
@@ -2307,13 +2017,17 @@ final class SybilViewModel {
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
|
||||
case let .done(payload):
|
||||
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
||||
payload.text
|
||||
}
|
||||
|
||||
case let .error(payload):
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -2491,68 +2205,29 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
|
||||
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
||||
guard let index else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveDone(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
var message = pending.messages[index]
|
||||
message.content = transform(message.content)
|
||||
pending.messages[index] = message
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyPendingAssistantError(chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
||||
guard let index else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveError()
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) {
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &quickQuestionMessages,
|
||||
assistantMessagePrefix: "temp-assistant-quick-"
|
||||
)
|
||||
}
|
||||
|
||||
private func applyStreamingAssistantContentUpdate(
|
||||
_ update: StreamingAssistantContentUpdate,
|
||||
to messages: inout [Message],
|
||||
assistantMessagePrefix: String
|
||||
) {
|
||||
guard case let .replace(content) = update,
|
||||
let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) })
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].content = content
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
}
|
||||
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
@@ -2560,16 +2235,6 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
|
||||
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||
pendingChatStates[chatID] = pending
|
||||
@@ -2588,12 +2253,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
let update = quickQuestionStreamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
|
||||
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||
return
|
||||
@@ -2743,8 +2402,7 @@ final class SybilViewModel {
|
||||
|
||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||
switch context {
|
||||
case let .draftChat(identity):
|
||||
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||
case .draftChat:
|
||||
pendingDraftChatState = nil
|
||||
case let .chat(chatID):
|
||||
pendingChatStates[chatID] = nil
|
||||
@@ -2772,7 +2430,7 @@ final class SybilViewModel {
|
||||
return chats.contains(where: { $0.id == chatID })
|
||||
case let .search(searchID):
|
||||
return searches.contains(where: { $0.id == searchID })
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2961,10 +2619,20 @@ final class SybilViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
||||
if chat.titleGenerationPending {
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
||||
|
||||
if let summary = chats.first(where: { $0.id == chatID }) {
|
||||
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ImageIO
|
||||
import Observation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
@@ -18,7 +19,6 @@ struct SybilWorkspaceView: View {
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@@ -155,6 +155,8 @@ struct SybilWorkspaceView: View {
|
||||
workspaceContentStack
|
||||
|
||||
if showsCustomWorkspaceNavigation {
|
||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
||||
.allowsHitTesting(false)
|
||||
customWorkspaceNavigationBar
|
||||
}
|
||||
}
|
||||
@@ -173,8 +175,6 @@ struct SybilWorkspaceView: View {
|
||||
if isSettingsSelected {
|
||||
SybilSettingsView(viewModel: viewModel)
|
||||
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.selectedItem == .agent {
|
||||
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.isSearchMode {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.displayedSearch,
|
||||
@@ -193,12 +193,9 @@ struct SybilWorkspaceView: View {
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||
isForking: viewModel.isForkingChat,
|
||||
onForkResponse: forkResponse
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -232,28 +229,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func forkResponse(messageID: String) {
|
||||
guard viewModel.draftKind == nil,
|
||||
case let .chat(chatID) = viewModel.selectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
@@ -287,7 +262,7 @@ struct SybilWorkspaceView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
|
||||
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
|
||||
Text(selectedProviderModelSubtitle)
|
||||
.font(.sybil(size: 10, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
@@ -509,7 +484,7 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationTrailingControl: some View {
|
||||
if isSettingsSelected || viewModel.selectedItem == .agent {
|
||||
if isSettingsSelected {
|
||||
EmptyView()
|
||||
} else if viewModel.isSearchMode {
|
||||
searchModeNavigationLabel
|
||||
@@ -613,19 +588,46 @@ struct SybilWorkspaceView: View {
|
||||
.accessibilityLabel("Attach file")
|
||||
}
|
||||
|
||||
SybilPromptField(
|
||||
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||
TextField(
|
||||
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||
text: $viewModel.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($composerFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
submitComposer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
SybilPromptSendButton(
|
||||
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
|
||||
canSend: viewModel.canSendComposer,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
|
||||
Button {
|
||||
submitComposer()
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendComposer
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendComposer)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
@@ -1346,6 +1348,173 @@ private struct SybilNavigationFadeBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilWorkspaceCharacterBackdrop: View {
|
||||
var isBusy: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.13),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 10,
|
||||
endRadius: 150
|
||||
)
|
||||
.frame(width: 136, height: 118)
|
||||
.blur(radius: 7)
|
||||
.offset(x: 28, y: -24)
|
||||
|
||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
||||
.frame(width: 172, height: 172)
|
||||
.opacity(0.92)
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black,
|
||||
Color.black,
|
||||
Color.black.opacity(0)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.black.opacity(0.98),
|
||||
Color.black
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
.offset(x: 8, y: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
||||
var resourceName: String
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIImageView {
|
||||
let imageView = SybilAnimatedUIImageView()
|
||||
imageView.backgroundColor = .clear
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = false
|
||||
imageView.isOpaque = false
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
return imageView
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
||||
guard context.coordinator.resourceName != resourceName else {
|
||||
if !imageView.isAnimating {
|
||||
imageView.startAnimating()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
context.coordinator.resourceName = resourceName
|
||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
||||
imageView.startAnimating()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var resourceName: String?
|
||||
}
|
||||
}
|
||||
|
||||
private final class SybilAnimatedUIImageView: UIImageView {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum SybilAnimatedGIFCache {
|
||||
private static var images: [String: UIImage] = [:]
|
||||
|
||||
static func image(named name: String) -> UIImage? {
|
||||
if let cached = images[name] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let image = loadImage(named: name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
images[name] = image
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
private static func loadImage(named name: String) -> UIImage? {
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
||||
|
||||
guard let url,
|
||||
let data = try? Data(contentsOf: url),
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frameCount = CGImageSourceGetCount(source)
|
||||
guard frameCount > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var frames: [UIImage] = []
|
||||
frames.reserveCapacity(frameCount)
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
for index in 0 ..< frameCount {
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
||||
continue
|
||||
}
|
||||
frames.append(UIImage(cgImage: cgImage))
|
||||
duration += frameDuration(at: index, source: source)
|
||||
}
|
||||
|
||||
guard !frames.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if frames.count == 1 {
|
||||
return frames[0]
|
||||
}
|
||||
|
||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
||||
}
|
||||
|
||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
||||
else {
|
||||
return 0.08
|
||||
}
|
||||
|
||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
||||
return max(delay, 0.03)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
|
||||
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
|
||||
}
|
||||
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
|
||||
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
|
||||
}
|
||||
|
||||
private final class MemoryOutbox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var rows: [AgentOutboxRow] = []
|
||||
var storage: AgentOutboxStorage {
|
||||
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
|
||||
}
|
||||
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
|
||||
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
|
||||
}
|
||||
private actor ControlledAgentSocket: AgentSocket {
|
||||
let stream: AsyncThrowingStream<Data, any Error>
|
||||
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
|
||||
private(set) var closed = false
|
||||
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
|
||||
func receive() async throws -> Data {
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
guard let data = try await iterator.next() else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
func send(_ text: String) {}
|
||||
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
|
||||
func closeCode() -> Int { 0 }
|
||||
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
|
||||
}
|
||||
private actor TestAgentClient: AgentAPIClienting {
|
||||
var pages: [String: AgentPage]
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
var socketAfters: [String] = []
|
||||
var sockets: [ControlledAgentSocket] = []
|
||||
var holdRead = false
|
||||
var heldRead: CheckedContinuation<AgentReadState, Never>?
|
||||
var beforeSubmit: (@Sendable () -> Bool)?
|
||||
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
|
||||
func agentDescriptor() -> AgentDescriptor {
|
||||
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
|
||||
}
|
||||
func agentMessages(after: String?, before: String?) -> AgentPage {
|
||||
let cursor = after ?? "0"; requests.append(cursor)
|
||||
return pages[cursor] ?? agentPage([], cursor: cursor)
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
|
||||
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
|
||||
}
|
||||
func agentSocket(after: String) async -> any AgentSocket {
|
||||
socketAfters.append(after)
|
||||
let socket = ControlledAgentSocket(); sockets.append(socket)
|
||||
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
|
||||
return socket
|
||||
}
|
||||
func connectedAfters() -> [String] { socketAfters }
|
||||
func latestSocket() -> ControlledAgentSocket? { sockets.last }
|
||||
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
|
||||
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
|
||||
func submittedIDs() -> [String] { submissions.map(\.1) }
|
||||
func requestedCursors() -> [String] { requests }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func eventually(_ predicate: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 {
|
||||
if predicate() { return true }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
struct AgentTests {
|
||||
@Test @MainActor func mixedLoadedHistoryFiltersOnlyToolRows() async throws {
|
||||
var user = agentMessage("user", entry: "1", role: .user)
|
||||
user.content = "Explain the tool_call and tool result."
|
||||
let call = agentMessage("call", entry: "2", role: .tool, info: ["status": .string("initiated")])
|
||||
var reply = agentMessage("reply", entry: "3")
|
||||
reply.content = "The search tool failed; here is what you can do."
|
||||
// Role-only results still need hiding even without tool-call metadata.
|
||||
var result = agentMessage("result", entry: "4", role: .tool)
|
||||
result.metadata = nil
|
||||
let markedCall = agentMessage("marked-call", entry: "5", info: ["kind": .string("tool_call")])
|
||||
var plainReply = agentMessage("plain-reply", entry: "6")
|
||||
plainReply.metadata = nil
|
||||
let failure = agentMessage("failure", entry: "7", info: ["status": .string("failed"), "error": .string("Please try again")])
|
||||
let terminal = agentMessage("terminal", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("Raw tool failure"), "resultPreview": .string("Raw result")])
|
||||
let history = [user, call, reply, result, markedCall, plainReply, failure, terminal].enumerated().map { index, message in
|
||||
var message = message
|
||||
message.createdAt.addTimeInterval(Double(index))
|
||||
return message
|
||||
}
|
||||
let client = TestAgentClient(pages: ["0": agentPage(Array(history.dropLast()), cursor: "7", more: true), "7": agentPage([history[7]], cursor: "8")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(await client.requestedCursors() == ["0", "7"])
|
||||
#expect(await client.connectedAfters() == ["8"])
|
||||
let transcript = AgentTranscript(messages: room.messages)
|
||||
#expect(transcript.displayed == [history[0], history[2], history[5], history[6]])
|
||||
#expect(room.snapshot.room.messages == history)
|
||||
#expect(room.messages.count == 8)
|
||||
#expect(room.snapshot.room.cursor == "8")
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
#expect(room.unreadCount == 2)
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
// Shared regular-chat tool metadata remains available and unchanged.
|
||||
#expect(terminal.isToolCallLog)
|
||||
#expect(terminal.toolCallMetadata?.resultPreview == "Raw result")
|
||||
}
|
||||
|
||||
@Test func transcriptWindowsAndAnchorsUseVisibleRows() {
|
||||
var raw: [Message] = [], visible: [Message] = []
|
||||
for index in 1...160 {
|
||||
var message = agentMessage("reply-\(index)", entry: "\(index * 2)")
|
||||
message.createdAt.addTimeInterval(Double(index * 2) * 86_400)
|
||||
var tool = agentMessage("tool-\(index)", entry: "\(index * 2 + 1)", role: .tool)
|
||||
// Tools on separate dates must not introduce their own separators.
|
||||
tool.createdAt = message.createdAt.addingTimeInterval(86_400)
|
||||
raw += [message, tool]; visible.append(message)
|
||||
}
|
||||
let initial = AgentTranscript(messages: raw)
|
||||
#expect(initial.startIndex == 60)
|
||||
#expect(initial.displayed == Array(visible.suffix(100)))
|
||||
let anchorID = initial.displayed.first?.id
|
||||
let hidden = agentMessage("late-tool", entry: "400", role: .tool)
|
||||
let updated = AgentTranscript(messages: raw + [hidden], startID: anchorID)
|
||||
#expect(updated.displayed == initial.displayed)
|
||||
let earlierID = initial.messages[max(0, initial.startIndex - 100)].id
|
||||
let expanded = AgentTranscript(messages: raw, startID: earlierID, limit: 200)
|
||||
#expect(expanded.displayed == visible)
|
||||
#expect(expanded.displayed.firstIndex(where: { $0.id == anchorID }) == 60)
|
||||
let hiddenAnchor = AgentTranscript(messages: raw, startID: "tool-160")
|
||||
#expect(hiddenAnchor.displayed == initial.displayed)
|
||||
let onlyTools = AgentTranscript(messages: raw.filter { $0.role == .tool })
|
||||
#expect(onlyTools.messages.isEmpty)
|
||||
#expect(onlyTools.displayed.isEmpty)
|
||||
#expect(onlyTools.startIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func liveToolUpsertsPreserveRawHistoryCursorsAndActivity() async throws {
|
||||
let user = agentMessage("user", entry: "1", role: .user)
|
||||
let reply = agentMessage("reply", entry: "2")
|
||||
let client = TestAgentClient(pages: ["0": agentPage([user, reply], cursor: "2")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let queued = agentMessage("queued", entry: "0", role: .user, info: ["status": .string("queued")])
|
||||
var activity = AgentActivity()
|
||||
activity.isThinking = true; activity.backgroundWorkers = 2; activity.queued = 1
|
||||
activity.queuedMessages = [queued]
|
||||
activity.wait = .init(due: 1_800_000_000_000, reason: "Waiting for background work")
|
||||
activity.error = "A worker needs attention"
|
||||
try room.receive(.activity("root", activity)); room.flush()
|
||||
#expect(await eventually { room.snapshot.isTyping })
|
||||
let visible = AgentTranscript(messages: room.messages).displayed
|
||||
#expect(Set(visible.map(\.id)) == ["user", "reply", "queued"])
|
||||
let start = agentMessage("tool", entry: "3", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
|
||||
let completed = agentMessage("tool", entry: "4", role: .tool, info: ["status": .string("completed"), "resultPreview": .string("Raw preview")])
|
||||
let result = agentMessage("tool", entry: "5", role: .tool, info: ["status": .string("completed"), "result": .object(["matches": .number(3)])])
|
||||
for (message, cursor) in [(start, "3"), (completed, "4"), (result, "5"), (start, "3")] {
|
||||
try room.receive(.messages(agentPage([message], cursor: cursor))); room.flush()
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible)
|
||||
#expect(room.snapshot.room.messages.count == 3)
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
#expect(room.snapshot.isTyping)
|
||||
#expect(room.snapshot.room.activity == activity)
|
||||
}
|
||||
let tool = try #require(room.snapshot.room.messages.first(where: { $0.id == "tool" })?.toolCallMetadata)
|
||||
#expect(tool.status == "completed")
|
||||
#expect(tool.args == .object(["query": .string("fixture")]))
|
||||
#expect(tool.resultPreview == "Raw preview")
|
||||
#expect(tool.result == .object(["matches": .number(3)]))
|
||||
#expect(tool.startedAt == "start")
|
||||
#expect(room.snapshot.room.cursor == "5")
|
||||
// The rendered snapshot's cursor includes hidden tool updates. A later
|
||||
// reply must remain unread when only that captured cursor is acknowledged.
|
||||
room.isSelected = true
|
||||
let capturedCursor = room.snapshot.room.cursor
|
||||
let lateReply = agentMessage("late-reply", entry: "6")
|
||||
let lateTool = agentMessage("late-tool", entry: "7", role: .tool)
|
||||
try room.receive(.messages(agentPage([lateReply, lateTool], cursor: "7"))); room.flush()
|
||||
room.markRead(renderedCursor: capturedCursor)
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "5" })
|
||||
#expect(room.snapshot.room.cursor == "7")
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
|
||||
#expect(!room.snapshot.isTyping)
|
||||
#expect(room.isBusy)
|
||||
let socket = try #require(await client.latestSocket())
|
||||
await socket.push(#"{"type":"error","error":"Room connection unavailable"}"#)
|
||||
#expect(await eventually { room.error == "Room connection unavailable" })
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
|
||||
#expect(room.snapshot.room.messages.count == 5)
|
||||
}
|
||||
|
||||
@Test @MainActor func failedSendDetailsAndRetryStayVisibleWithHiddenTools() async throws {
|
||||
let tool = agentMessage("tool", entry: "1", role: .tool)
|
||||
let client = TestAgentClient(pages: ["0": agentPage([tool], cursor: "1")])
|
||||
await client.setFailures(submit: true)
|
||||
let memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(room.send("Please retry the search tool"))
|
||||
#expect(await eventually { room.outbox.first?.status == "failed" })
|
||||
let failed = try #require(AgentTranscript(messages: room.messages).displayed.first)
|
||||
let requestID = try #require(room.outbox.first?.requestID)
|
||||
#expect(failed.role == .user)
|
||||
#expect(failed.content == "Please retry the search tool")
|
||||
#expect(failed.metadata?.objectValue?["status"] == .string("failed"))
|
||||
#expect(failed.metadata?.objectValue?["error"] == .string("Lost acknowledgement"))
|
||||
#expect(AgentTranscript(messages: room.messages).displayed.count == 1)
|
||||
#expect(memory.read().first?.requestID == requestID)
|
||||
await client.setFailures()
|
||||
room.retry(failed.id)
|
||||
#expect(await eventually { room.outbox.first?.status == "queued" })
|
||||
let retried = try #require(AgentTranscript(messages: room.messages).displayed.first)
|
||||
#expect(retried.id == failed.id)
|
||||
#expect(retried.createdAt == failed.createdAt)
|
||||
#expect(retried.metadata?.objectValue?["status"] == .string("queued"))
|
||||
#expect(retried.metadata?.objectValue?["error"] == nil)
|
||||
#expect(await client.submittedIDs() == [requestID, requestID])
|
||||
#expect(room.snapshot.room.messages == [tool])
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test func cursorAndMalformedFrames() throws {
|
||||
#expect(try AgentCursor.normalize("00012") == "12")
|
||||
#expect(try AgentCursor.advance("9", "10") == "10")
|
||||
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
|
||||
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
|
||||
let original = state
|
||||
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
|
||||
#expect(state == original)
|
||||
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
|
||||
#expect(state.cursor == "10")
|
||||
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
|
||||
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
|
||||
}
|
||||
|
||||
@Test func terminalToolAndRestSocketRace() throws {
|
||||
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
|
||||
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
|
||||
end.createdAt.addTimeInterval(5)
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
#expect(state.cursor == "9")
|
||||
#expect(state.messages.count == 2)
|
||||
let tool = try #require(state.messages.first?.toolCallMetadata)
|
||||
#expect(tool.status == "failed")
|
||||
#expect(tool.error == "fixture failure")
|
||||
#expect(tool.args == .object(["query": .string("fixture")]))
|
||||
#expect(tool.startedAt == "start")
|
||||
#expect(state.messages.first?.createdAt == start.createdAt)
|
||||
}
|
||||
|
||||
@Test func unreadExcludesToolsAndUsers() throws {
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
|
||||
state.readCursor = "9"
|
||||
#expect(state.unreadCount == 1)
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
|
||||
#expect(state.unreadCount == 0)
|
||||
}
|
||||
|
||||
@Test func typingReplyAndStaleActivityAreAtomic() throws {
|
||||
var presentation = AgentPresentation(), state = AgentRoomState()
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
|
||||
var after = state; after.activity = busy
|
||||
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
|
||||
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
|
||||
try after.apply(reply)
|
||||
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
|
||||
#expect(state.activity.isBusy)
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
|
||||
for name in AgentPresentation.quietTools {
|
||||
busy.tools.append(.init(id: name, name: name, status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
|
||||
}
|
||||
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
||||
}
|
||||
|
||||
@Test func socketURLAndAuthentication() throws {
|
||||
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
||||
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
|
||||
#expect(frame.objectValue?["token"] == .string("token"))
|
||||
#expect(frame.objectValue?["after"] == .string("9"))
|
||||
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
|
||||
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
|
||||
#expect(openFrame.objectValue?["token"] == .null)
|
||||
}
|
||||
|
||||
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(await client.requestedCursors() == ["0", "3", "7"])
|
||||
#expect(room.snapshot.room.cursor == "8")
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs == ["late"])
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
|
||||
let memory = MemoryOutbox(), client = TestAgentClient()
|
||||
await client.setFailures(submit: true)
|
||||
await client.requirePersisted { !memory.read().isEmpty }
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(room.send("identical")); #expect(room.send("identical"))
|
||||
#expect(room.messages.count == 2)
|
||||
let first = room.outbox[0], second = room.outbox[1]
|
||||
#expect(first.requestID != second.requestID)
|
||||
#expect(memory.read().count == 2)
|
||||
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
|
||||
room.flush()
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
#expect(room.outbox[0].status == "complete")
|
||||
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
|
||||
room.retry(second.id)
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
let ids = await client.submittedIDs()
|
||||
#expect(ids.filter { $0 == second.requestID }.count == 2)
|
||||
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
|
||||
defer { restored.stop() }
|
||||
#expect(restored.outbox.count == 1)
|
||||
#expect(restored.outbox[0].requestID == second.requestID)
|
||||
#expect(restored.outbox[0].createdAt == second.createdAt)
|
||||
}
|
||||
|
||||
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
|
||||
let client = TestAgentClient(); await client.setFailures(malformed: true)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.send("fixture")
|
||||
#expect(await eventually { room.outbox.first?.status == "failed" })
|
||||
#expect(room.outbox.first?.seenOnServer == false)
|
||||
let failed = SybilAgentRoom()
|
||||
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
|
||||
defer { failed.stop() }
|
||||
failed.send("must not submit")
|
||||
#expect(failed.outbox.first?.status == "failed")
|
||||
#expect(await client.submittedIDs().count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.isSelected = true
|
||||
await client.setFailures(read: true)
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.error != nil })
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
await client.setFailures()
|
||||
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
#expect(room.unreadCount == 1)
|
||||
room.isObscured = true; room.markRead(renderedCursor: "2")
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
#expect(room.snapshot.room.readCursor == "1")
|
||||
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
|
||||
#expect(room.unreadCount == 0)
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.send("interrupted fixture")
|
||||
let original = try #require(room.outbox.first)
|
||||
room.setActive(false)
|
||||
#expect(room.outbox.first?.status == "failed")
|
||||
#expect(memory.read().first?.requestID == original.requestID)
|
||||
room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.retry(original.id)
|
||||
#expect(await eventually { room.outbox.first?.seenOnServer == true })
|
||||
#expect(room.outbox.first?.createdAt == original.createdAt)
|
||||
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
|
||||
}
|
||||
|
||||
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
|
||||
let old = TestAgentClient(); await old.pauseRead()
|
||||
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
|
||||
for _ in 0..<100 {
|
||||
if await old.hasHeldRead() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
room.disconnect()
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
|
||||
room.configure(client: next, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
await old.releaseRead()
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
try await room.catchUp(client: next)
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
#expect(await next.requestedCursors() == ["0", "2"])
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
|
||||
let client = TestAgentClient()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let socket = try #require(await client.latestSocket())
|
||||
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
|
||||
await socket.push(frame)
|
||||
#expect(await eventually { room.snapshot.room.cursor == "7" })
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let next = try #require(await client.latestSocket())
|
||||
await next.push(frame)
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
#expect(await client.connectedAfters() == ["0", "7"])
|
||||
#expect(room.messages.count == 1)
|
||||
#expect(await socket.closed)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-152
@@ -1,152 +0,0 @@
|
||||
# Sybil for iOS and Mac Catalyst
|
||||
|
||||
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
||||
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
|
||||
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
||||
release/archive signing settings are unchanged.
|
||||
|
||||
The conversation sidebar includes **Search chats and web searches** on iPhone,
|
||||
iPad, and Mac. It searches all saved history by title, conversation text, and saved
|
||||
web-search query/answer, with single-line excerpts that bold matching words and
|
||||
truncate in the middle. Search waits briefly while typing,
|
||||
cancels superseded requests, and leaves the current conversation open. Clear the
|
||||
field to return to recent items. Searches use word prefixes (for example, `plan`
|
||||
finds `planning`); if more than 100 items match, refine the query.
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
The permanent **Sybil Agent** sidebar item opens the persistent room on both
|
||||
phone and iPad. It uses the configured Sybil API URL (including an explicit
|
||||
`/api` path) and token. The room stays subscribed while other chats are selected;
|
||||
foreground and network recovery replay from the last merged cursor. Shared unread
|
||||
counts include only public assistant replies and are acknowledged after the
|
||||
visible transcript reaches the bottom and its entrance animation finishes.
|
||||
|
||||
Multiple messages can be queued while the Agent works. The local outbox is saved
|
||||
atomically under Application Support before submission and is scoped to the API
|
||||
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
|
||||
its request ID and timestamp across relaunch. A canonical server echo confirms it
|
||||
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
|
||||
in the Agent transcript, including loaded history and live updates. History windows,
|
||||
date separators and scroll anchors use visible messages; raw merged history and
|
||||
cursors remain intact. Send failures, room errors and activity indicators remain
|
||||
visible. Attachments, model selection and tool cards belong to ordinary chats.
|
||||
|
||||
Agent contracts remain in `../docs/api/rest.md` and
|
||||
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
|
||||
|
||||
### Agent regression and Simulator checks
|
||||
|
||||
Run these commands from this worktree's `ios/` directory:
|
||||
|
||||
```sh
|
||||
just build
|
||||
just test
|
||||
just run
|
||||
just screenshot build/agent-evidence/simulator.png
|
||||
```
|
||||
|
||||
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
|
||||
regressions along with the existing app tests through Xcode on iPhone 16e.
|
||||
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
|
||||
|
||||
```sh
|
||||
python3 Tests/Fixtures/agent_server.py
|
||||
```
|
||||
|
||||
In a second terminal, run:
|
||||
|
||||
```sh
|
||||
just generate
|
||||
xcodebuild test -scheme SybilAgentUI \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||
-parallel-testing-enabled NO \
|
||||
-resultBundlePath build/agent-evidence/phone-ui.xcresult
|
||||
```
|
||||
|
||||
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
|
||||
external services. UI tests exercise the real REST/WebSocket client and retain
|
||||
screenshots in the result bundle. They cover queued submissions, uncertain
|
||||
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
|
||||
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
|
||||
anchors and multiline keyboard layout. The launch arguments select the fixture
|
||||
through ordinary API settings; no fixture mode is compiled into the application.
|
||||
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
|
||||
Simulator Reduce Motion enabled before release.
|
||||
|
||||
## Quick Question on macOS
|
||||
|
||||
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
||||
Question panel, including over other applications. The prompt is focused as soon
|
||||
as it opens, with any existing prompt selected so typing replaces it. The same
|
||||
select-on-open behavior applies to Quick Question on iPhone and iPad.
|
||||
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
|
||||
second **Option+Space**, the close button, or clicking outside dismisses it.
|
||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||
**Open in Chat** saves the question and answer in the regular workspace.
|
||||
|
||||
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||
Question, while the normal chat/search composer continues to use Return for a
|
||||
new line.
|
||||
|
||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||
build opts out of automatic termination, and the native shortcut service holds a
|
||||
matching automatic-termination activity until it stops. No hidden keep-alive
|
||||
window or sleep-preventing activity is used. Mac scene support also allows the
|
||||
workspace to reopen after its last window is closed.
|
||||
|
||||
When Catalyst is backgrounded, opening Quick Question first requests app
|
||||
activation and waits for UIKit to enter the foreground before creating or ordering
|
||||
the native panel. Presentation is deferred out of the hotkey event callback.
|
||||
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
|
||||
that required activation returns focus to the previous app; explicitly opening
|
||||
the workspace keeps focus in Sybil.
|
||||
|
||||
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
|
||||
workspace window when none are open. **Command+Shift+N** always opens a new window,
|
||||
including while signed out. **Command+Option+N** starts a new search; iOS retains
|
||||
its existing Command+Shift+N search shortcut.
|
||||
|
||||
The shortcut is also shown under **File → Quick Question**. If another application
|
||||
already owns Option+Space, the panel reports that the shortcut is unavailable;
|
||||
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
|
||||
needed for the registered hotkey.
|
||||
|
||||
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
|
||||
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
|
||||
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
|
||||
provider/model preferences, streaming, and conversion to chat remain in the
|
||||
existing `SybilViewModel`; there is no separate backend or API contract. The panel
|
||||
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
|
||||
Catalyst process.
|
||||
|
||||
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
|
||||
action, and does not build or embed the macOS helper.
|
||||
|
||||
## Verification
|
||||
|
||||
- `just test`: existing Swift package tests on the iPhone simulator.
|
||||
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
|
||||
- For a manual Mac check, open the panel from another app, type immediately,
|
||||
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
|
||||
returns input to the previous app. Check a second display and a full-screen Space
|
||||
when available.
|
||||
- Close every Mac window, invoke Option+Space, and verify the saved question and
|
||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||
it. On iOS, verify Return submits Quick Question while Return in the normal
|
||||
prompt adds a newline. In the Mac workspace, verify Return submits and
|
||||
Shift+Return adds a newline.
|
||||
- Check Command+N both with a workspace open and after closing every workspace.
|
||||
Command+Shift+N must open another window even when one already exists, and
|
||||
Command+Option+N must start a search without opening a window.
|
||||
- With all workspace windows closed, switch to another app and repeatedly open
|
||||
and dismiss Quick Question, including two quick Option+Space presses while it
|
||||
is opening. Check that no late panel appears after cancellation and that no
|
||||
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only deterministic Agent contract fixture. Never contacts external services.
|
||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
from aiohttp import web
|
||||
|
||||
sockets = set()
|
||||
messages = []
|
||||
requests = {}
|
||||
read_cursor = 0
|
||||
cursor = 0
|
||||
busy = False
|
||||
attempts = {}
|
||||
log = []
|
||||
reply_tasks = set()
|
||||
|
||||
def stamp():
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
def activity():
|
||||
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
|
||||
backgroundWorkers=0, tools=[], queuedMessages=[])
|
||||
|
||||
def message(content, role='assistant', id=None, info=None):
|
||||
global cursor
|
||||
cursor += 1
|
||||
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
|
||||
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
|
||||
messages.append(row)
|
||||
return row
|
||||
|
||||
def page(rows, position=None, more=False):
|
||||
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
|
||||
|
||||
async def broadcast(frame):
|
||||
for socket in list(sockets):
|
||||
try:
|
||||
await socket.send_json(frame)
|
||||
except ConnectionError:
|
||||
sockets.discard(socket)
|
||||
|
||||
async def emit(content):
|
||||
row = message(content)
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
return row
|
||||
|
||||
async def reply(content):
|
||||
global busy, cursor
|
||||
await asyncio.sleep(1.2)
|
||||
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
|
||||
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
|
||||
await broadcast(dict(type='messages', **page([tool])))
|
||||
await asyncio.sleep(0.8)
|
||||
cursor += 1
|
||||
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
|
||||
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
|
||||
await broadcast(dict(type='messages', **page([tool])))
|
||||
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
|
||||
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
|
||||
await broadcast(dict(type='messages', **page([failed])))
|
||||
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
|
||||
# Deliberately stale busy frame after the public reply.
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
await asyncio.sleep(0.5)
|
||||
busy = False
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
body = await request.json()
|
||||
action = body.get('action')
|
||||
if action == 'reset':
|
||||
for task in reply_tasks:
|
||||
task.cancel()
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'reply':
|
||||
await emit(body.get('content', 'Late asynchronous fixture reply'))
|
||||
elif action == 'long-tool':
|
||||
row = message('Local tool output', 'tool')
|
||||
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
|
||||
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'busy':
|
||||
busy = body.get('value', True)
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
elif action == 'read':
|
||||
read_cursor = cursor
|
||||
elif action == 'disconnect':
|
||||
for socket in list(sockets):
|
||||
await socket.close(code=1012)
|
||||
elif action == 'malformed':
|
||||
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
if path == '/api/v1/auth/session':
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
if path == '/api/v1/workspace-items':
|
||||
return web.json_response(dict(items=[], hasMore=False))
|
||||
if path == '/api/v1/chats':
|
||||
return web.json_response(dict(chats=[]))
|
||||
if path == '/api/v1/searches':
|
||||
return web.json_response(dict(searches=[]))
|
||||
if path == '/api/v1/models':
|
||||
return web.json_response(dict(providers={}))
|
||||
if path == '/api/v1/active-runs':
|
||||
return web.json_response(dict(chats=[], searches=[]))
|
||||
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
|
||||
if request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(error='Fixture token missing'), status=401)
|
||||
if path == '/api/v1/agent':
|
||||
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
||||
if path == '/api/v1/agent/messages':
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
key, content = body['requestId'], body['content']
|
||||
attempts[key] = attempts.get(key, 0) + 1
|
||||
if content == 'retry fixture' and attempts[key] == 1:
|
||||
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
|
||||
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
|
||||
if key not in requests:
|
||||
requests[key] = receipt
|
||||
row = message(content, 'user', id=receipt['messageId'])
|
||||
busy = True
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
task = asyncio.create_task(reply(content))
|
||||
reply_tasks.add(task)
|
||||
task.add_done_callback(reply_tasks.discard)
|
||||
if content == 'echo wins fixture':
|
||||
await asyncio.sleep(0.3)
|
||||
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
|
||||
return web.json_response(requests[key], status=202)
|
||||
after = int(request.query.get('after', 0))
|
||||
rows = [m for m in messages if m['metadata']['entryId'] > after]
|
||||
limit = min(int(request.query.get('limit', 200)), 200)
|
||||
selected = rows[:limit]
|
||||
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
|
||||
if path == '/api/v1/agent/ws':
|
||||
if request.query:
|
||||
return web.Response(status=400)
|
||||
socket = web.WebSocketResponse(); await socket.prepare(request)
|
||||
auth = await socket.receive_json(timeout=5)
|
||||
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
|
||||
await socket.close(code=4401); return socket
|
||||
log.append(dict(authAfter=auth.get('after')))
|
||||
sockets.add(socket)
|
||||
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
|
||||
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
|
||||
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
try:
|
||||
async for frame in socket:
|
||||
if frame.type == web.WSMsgType.TEXT:
|
||||
data = json.loads(frame.data)
|
||||
if data.get('type') == 'ping':
|
||||
await socket.send_json(dict(type='pong'))
|
||||
finally:
|
||||
sockets.discard(socket)
|
||||
return socket
|
||||
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
|
||||
|
||||
for i in range(3):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
app = web.Application()
|
||||
app.router.add_route('*', '/{path:.*}', route)
|
||||
web.run_app(app, host='127.0.0.1', port=8799)
|
||||
@@ -1,172 +0,0 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class AgentUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
try await control(["action": "reset", "count": 3])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
openAgent()
|
||||
}
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists)
|
||||
}
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
private func send(_ text: String) {
|
||||
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
func testSendQueueToolsAndRetry() async throws {
|
||||
capture("agent-history")
|
||||
send("first fixture")
|
||||
send("second fixture")
|
||||
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-replies-keyboard")
|
||||
app.swipeDown()
|
||||
send("retry fixture")
|
||||
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
|
||||
capture("agent-retry")
|
||||
app.buttons["agent-retry"].tap()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
send("echo wins fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
composer.tap(); app.swipeDown()
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
|
||||
capture("agent-hidden-tools")
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
||||
}
|
||||
func testReduceMotionSend() async throws {
|
||||
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
||||
send("reduce motion fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-reduce-motion")
|
||||
}
|
||||
|
||||
func testToolResultsStayHiddenLiveAndAfterReload() async throws {
|
||||
try await control(["action": "long-tool"])
|
||||
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
capture("agent-hidden-tool-live")
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
|
||||
capture("agent-hidden-tool-history")
|
||||
}
|
||||
|
||||
func testTabletSidebarAndInactiveChat() async throws {
|
||||
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
|
||||
app.buttons["New chat"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "iPad inactive chat reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-ipad-sidebar")
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
|
||||
capture("agent-ipad-room-before-send")
|
||||
send("iPad fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
capture("agent-ipad-room")
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
func testScrollPreservationAndKeyboard() async throws {
|
||||
app.terminate()
|
||||
try await control(["action": "reset", "count": 160])
|
||||
app.launch(); openAgent()
|
||||
let scroll = app.scrollViews.firstMatch
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown()
|
||||
scroll.swipeDown()
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||
let before = anchor.frame.minY
|
||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
capture("agent-reading-anchor")
|
||||
for _ in 0..<25 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
app.buttons["Load earlier messages"].tap()
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
||||
capture("agent-older-history-anchor")
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
capture("agent-multiline-keyboard")
|
||||
}
|
||||
|
||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||
app.buttons["Show sidebar"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-unread-sidebar")
|
||||
let covered = try await control(["action": "state"])
|
||||
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
|
||||
try await control(["action": "read"])
|
||||
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
|
||||
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [cleared], timeout: 20)
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
|
||||
try await control(["action": "disconnect"])
|
||||
try await control(["action": "reply", "content": "Reconnect fixture reply"])
|
||||
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
|
||||
try await control(["action": "malformed"])
|
||||
try await control(["action": "reply", "content": "Recovered malformed frame"])
|
||||
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
|
||||
XCUIDevice.shared.press(.home)
|
||||
try await control(["action": "reply", "content": "Foreground fixture reply"])
|
||||
app.activate()
|
||||
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
|
||||
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
|
||||
capture("agent-foreground-replay")
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
app_identifier("net.buzzert.sybil2")
|
||||
team_id("DQQH5H6GBD")
|
||||
require "dotenv"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||
|
||||
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# TestFlight Release CI
|
||||
|
||||
Gitea Actions publishes iOS releases from tags that match:
|
||||
|
||||
```sh
|
||||
release/vN.N.N
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
git tag release/v1.10.0
|
||||
git push origin release/v1.10.0
|
||||
```
|
||||
|
||||
The release job runs on the `xcode` runner label, imports the signing p12 into
|
||||
a temporary keychain, installs the App Store provisioning profile, builds and
|
||||
uploads the app with fastlane, then creates or updates the matching Gitea
|
||||
release with the generated IPA as an asset. The job deletes the temporary
|
||||
signing keychain in an `always()` cleanup step.
|
||||
|
||||
Required repository secrets:
|
||||
|
||||
```text
|
||||
APP_STORE_CONNECT_API_KEY_ID
|
||||
APP_STORE_CONNECT_API_ISSUER_ID
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT
|
||||
APPSTORE_CERTIFICATES_FILE_BASE64
|
||||
APPSTORE_CERTIFICATES_PASSWORD
|
||||
APPSTORE_PROVISIONING_PROFILE_BASE64
|
||||
```
|
||||
|
||||
Generate or refresh the signing assets locally with:
|
||||
|
||||
```sh
|
||||
cd ios
|
||||
fastlane ios create_ci_signing
|
||||
```
|
||||
|
||||
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
|
||||
certificate and provisioning profile values into the repository secrets listed
|
||||
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
|
||||
default.
|
||||
|
||||
If the Apple team has reached the Distribution certificate limit, set
|
||||
`SYBIL_SIGNING_CERTIFICATE_ID` to the portal id for a certificate whose private
|
||||
key exists in the local login keychain before running `create_ci_signing`. The
|
||||
lane will export the local identity and create the provisioning profile against
|
||||
that existing certificate instead of creating another Distribution certificate.
|
||||
|
||||
If `create_ci_signing` fails with an expired or missing agreement error, the
|
||||
Apple Developer Program account holder must accept the current agreements in
|
||||
App Store Connect before new certificates or provisioning profiles can be
|
||||
created through the API.
|
||||
|
||||
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
|
||||
upload, with `contents: write` permissions. In Gitea this covers release asset
|
||||
publication.
|
||||
+374
-29
@@ -1,47 +1,392 @@
|
||||
require "dotenv"
|
||||
require "base64"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "net/http"
|
||||
require "open3"
|
||||
require "openssl"
|
||||
require "securerandom"
|
||||
require "shellwords"
|
||||
require "uri"
|
||||
require "yaml"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
|
||||
SIGNING_CERTIFICATE_NAME = ENV["SYBIL_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution: James Magahern (DQQH5H6GBD)" : ENV["SYBIL_CODE_SIGN_IDENTITY"]
|
||||
IOS_ROOT = File.expand_path("..", __dir__)
|
||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
|
||||
SCHEME = "Sybil"
|
||||
TARGET = "SybilApp"
|
||||
|
||||
def present?(value)
|
||||
!value.to_s.strip.empty?
|
||||
end
|
||||
|
||||
def capture(command)
|
||||
stdout, stderr, status = Open3.capture3(command)
|
||||
return stdout.strip if status.success?
|
||||
|
||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
def run_silent(*command, error_message:)
|
||||
_stdout, stderr, status = Open3.capture3(*command)
|
||||
return if status.success?
|
||||
|
||||
UI.user_error!("#{error_message}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
def user_keychains
|
||||
capture("security list-keychains -d user").lines.map { |line| line.strip.delete('"') }.reject(&:empty?)
|
||||
end
|
||||
|
||||
def app_project_settings
|
||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||
end
|
||||
|
||||
def local_marketing_version
|
||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||
end
|
||||
|
||||
def local_build_number
|
||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||
end
|
||||
|
||||
def normalize_version_tag(tag)
|
||||
version = tag.to_s.strip.sub(%r{\Arelease/}, "").sub(/\Av/, "")
|
||||
unless version.match?(/\A\d+\.\d+\.\d+\z/)
|
||||
UI.user_error!("Release tag #{tag.inspect} must look like release/v1.10.0")
|
||||
end
|
||||
version
|
||||
end
|
||||
|
||||
def release_version
|
||||
tag = ENV["SYBIL_VERSION_TAG"]
|
||||
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||
normalize_version_tag(tag)
|
||||
end
|
||||
|
||||
def xcode_build_setting(key, value)
|
||||
"#{key}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def env_line(key, value)
|
||||
"#{key}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def base64url(value)
|
||||
Base64.urlsafe_encode64(value).delete("=")
|
||||
end
|
||||
|
||||
def integer_to_fixed_bytes(integer, length)
|
||||
hex = integer.to_s(16)
|
||||
hex = "0#{hex}" if hex.length.odd?
|
||||
[hex].pack("H*").rjust(length, "\0")[-length, length]
|
||||
end
|
||||
|
||||
def app_store_connect_private_key
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
|
||||
pem = if present?(key_path)
|
||||
File.read(key_path)
|
||||
elsif present?(key_content)
|
||||
ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true" ? Base64.decode64(key_content) : key_content
|
||||
end
|
||||
UI.user_error!("App Store Connect API key content is required") unless present?(pem)
|
||||
|
||||
OpenSSL::PKey::EC.new(pem)
|
||||
end
|
||||
|
||||
def app_store_connect_jwt
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||
UI.user_error!("App Store Connect API key id and issuer id are required") unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
header = { alg: "ES256", kid: key_id, typ: "JWT" }
|
||||
payload = { iss: issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + 600, aud: "appstoreconnect-v1" }
|
||||
unsigned = [base64url(header.to_json), base64url(payload.to_json)].join(".")
|
||||
asn1_signature = app_store_connect_private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(unsigned))
|
||||
signature_sequence = OpenSSL::ASN1.decode(asn1_signature)
|
||||
raw_signature = signature_sequence.value.map { |part| integer_to_fixed_bytes(part.value, 32) }.join
|
||||
[unsigned, base64url(raw_signature)].join(".")
|
||||
end
|
||||
|
||||
def app_store_connect_request(method, path, payload = nil)
|
||||
uri = URI("https://api.appstoreconnect.apple.com#{path}")
|
||||
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
||||
request = request_class.new(uri)
|
||||
request["Authorization"] = "Bearer #{app_store_connect_jwt}"
|
||||
if payload
|
||||
request["Content-Type"] = "application/json"
|
||||
request.body = payload.to_json
|
||||
end
|
||||
|
||||
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
||||
return {} if response.is_a?(Net::HTTPSuccess) && response.body.to_s.empty?
|
||||
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
UI.user_error!("App Store Connect API request failed: #{method.to_s.upcase} #{path}\n#{response.body}")
|
||||
end
|
||||
|
||||
def bundle_id_resource_id
|
||||
response = app_store_connect_request(
|
||||
:get,
|
||||
"/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1"
|
||||
)
|
||||
id = response.fetch("data", []).first&.fetch("id", nil)
|
||||
UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id)
|
||||
id
|
||||
end
|
||||
|
||||
def recreate_app_store_profile(certificate_id)
|
||||
existing = app_store_connect_request(
|
||||
:get,
|
||||
"/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200"
|
||||
)
|
||||
existing.fetch("data", []).each do |profile|
|
||||
app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}")
|
||||
end
|
||||
|
||||
payload = {
|
||||
data: {
|
||||
type: "profiles",
|
||||
attributes: {
|
||||
name: PROFILE_SPECIFIER,
|
||||
profileType: "IOS_APP_STORE"
|
||||
},
|
||||
relationships: {
|
||||
bundleId: {
|
||||
data: { type: "bundleIds", id: bundle_id_resource_id }
|
||||
},
|
||||
certificates: {
|
||||
data: [{ type: "certificates", id: certificate_id }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response = app_store_connect_request(:post, "/v1/profiles", payload)
|
||||
profile_content = response.dig("data", "attributes", "profileContent")
|
||||
UI.user_error!("App Store Connect profile response did not include profileContent") unless present?(profile_content)
|
||||
|
||||
profile_path = File.join(SIGNING_OUTPUT_DIR, "Sybil_AppStore_CI.mobileprovision")
|
||||
File.binwrite(profile_path, Base64.decode64(profile_content))
|
||||
install_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
|
||||
FileUtils.mkdir_p(install_dir)
|
||||
FileUtils.cp(profile_path, File.join(install_dir, "Sybil_AppStore_CI.mobileprovision"))
|
||||
profile_path
|
||||
end
|
||||
|
||||
def app_store_connect_key_options
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||
return nil unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
if present?(key_path)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_filepath: key_path
|
||||
}
|
||||
elsif present?(key_content)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_content: key_content,
|
||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Build a release tag and upload it to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci
|
||||
private_lane :load_app_store_connect_api_key do
|
||||
options = app_store_connect_key_options
|
||||
UI.user_error!("App Store Connect API key is required") unless options
|
||||
|
||||
match(type: "appstore")
|
||||
app_store_connect_api_key(options)
|
||||
end
|
||||
|
||||
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
|
||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||
lane :version do
|
||||
UI.message("Git tag version: #{release_version}")
|
||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||
UI.message("Checked-in build number: #{local_build_number}")
|
||||
end
|
||||
|
||||
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/)
|
||||
desc "Create CI signing certificate/profile and write ignored secret material under build/signing"
|
||||
lane :create_ci_signing do
|
||||
api_key = load_app_store_connect_api_key
|
||||
|
||||
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}")
|
||||
FileUtils.rm_rf(SIGNING_OUTPUT_DIR)
|
||||
FileUtils.mkdir_p(SIGNING_OUTPUT_DIR)
|
||||
|
||||
cert_id = ENV["SYBIL_SIGNING_CERTIFICATE_ID"].to_s
|
||||
keychain_path = nil
|
||||
keychain_password = nil
|
||||
p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12")
|
||||
p12_password = ENV["SYBIL_CI_P12_PASSWORD"].to_s
|
||||
if p12_password.empty?
|
||||
p12_password = SecureRandom.base64(24)
|
||||
UI.important("Generated a p12 password for CI secrets.")
|
||||
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"))
|
||||
begin
|
||||
if present?(cert_id)
|
||||
UI.message("Using existing signing certificate id #{cert_id}")
|
||||
export_keychain = ENV["SYBIL_SIGNING_KEYCHAIN"].to_s
|
||||
export_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") unless present?(export_keychain)
|
||||
run_silent(
|
||||
"security", "export", "-k", export_keychain, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||
error_message: "Could not export the local CI signing identity"
|
||||
)
|
||||
else
|
||||
keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db")
|
||||
keychain_password = SecureRandom.base64(24)
|
||||
run_silent(
|
||||
"security", "create-keychain", "-p", keychain_password, keychain_path,
|
||||
error_message: "Could not create temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "set-keychain-settings", "-lut", "21600", keychain_path,
|
||||
error_message: "Could not configure temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "unlock-keychain", "-p", keychain_password, keychain_path,
|
||||
error_message: "Could not unlock temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "list-keychains", "-d", "user", "-s", keychain_path, *user_keychains,
|
||||
error_message: "Could not add temporary signing keychain to the user search list"
|
||||
)
|
||||
|
||||
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
|
||||
cert(
|
||||
api_key: api_key,
|
||||
development: false,
|
||||
force: true,
|
||||
generate_apple_certs: true,
|
||||
keychain_password: keychain_password,
|
||||
keychain_path: keychain_path,
|
||||
output_path: SIGNING_OUTPUT_DIR,
|
||||
platform: "ios"
|
||||
)
|
||||
|
||||
cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID]
|
||||
UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id)
|
||||
run_silent(
|
||||
"security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||
error_message: "Could not export the generated CI signing identity"
|
||||
)
|
||||
end
|
||||
|
||||
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
|
||||
|
||||
profile_path = recreate_app_store_profile(cert_id)
|
||||
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
|
||||
|
||||
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
|
||||
File.write(
|
||||
secrets_path,
|
||||
[
|
||||
env_line("APPSTORE_CERTIFICATES_FILE_BASE64", Base64.strict_encode64(File.binread(p12_path))),
|
||||
env_line("APPSTORE_CERTIFICATES_PASSWORD", p12_password),
|
||||
env_line("APPSTORE_PROVISIONING_PROFILE_BASE64", Base64.strict_encode64(File.binread(profile_path))),
|
||||
env_line("SYBIL_PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER)
|
||||
].join("\n") + "\n"
|
||||
)
|
||||
ensure
|
||||
system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if present?(keychain_path) && File.exist?(keychain_path)
|
||||
end
|
||||
|
||||
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
|
||||
UI.important("Add the values from #{secrets_path} as repository secrets.")
|
||||
end
|
||||
|
||||
desc "Build Sybil and upload it to TestFlight"
|
||||
lane :beta do
|
||||
version = release_version
|
||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||
api_key = load_app_store_connect_api_key
|
||||
|
||||
unless present?(build_number)
|
||||
build_number = (local_build_number + 1).to_s
|
||||
|
||||
begin
|
||||
latest = latest_testflight_build_number(
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
version: version,
|
||||
api_key: api_key,
|
||||
initial_build_number: local_build_number
|
||||
).to_i
|
||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||
rescue StandardError => e
|
||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||
end
|
||||
end
|
||||
|
||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||
|
||||
xcode_args = [
|
||||
xcode_build_setting("MARKETING_VERSION", version),
|
||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number),
|
||||
xcode_build_setting("CODE_SIGN_STYLE", "Manual"),
|
||||
xcode_build_setting("DEVELOPMENT_TEAM", TEAM_ID),
|
||||
xcode_build_setting("PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER),
|
||||
xcode_build_setting("CODE_SIGN_IDENTITY", SIGNING_CERTIFICATE_NAME)
|
||||
]
|
||||
if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
|
||||
xcode_args << xcode_build_setting("OTHER_CODE_SIGN_FLAGS", "--keychain #{ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH")}")
|
||||
end
|
||||
xcode_args = xcode_args.join(" ")
|
||||
|
||||
ipa_path = build_app(
|
||||
project: PROJECT_FILE,
|
||||
scheme: SCHEME,
|
||||
clean: true,
|
||||
sdk: "iphoneos",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||
xcargs: xcode_args,
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
destination: "export",
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: {
|
||||
APP_IDENTIFIER => PROFILE_SPECIFIER
|
||||
},
|
||||
signingCertificate: SIGNING_CERTIFICATE_NAME,
|
||||
teamID: TEAM_ID,
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
uploadSymbols: true,
|
||||
stripSwiftSymbols: true
|
||||
}
|
||||
)
|
||||
|
||||
build_app(
|
||||
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
||||
scheme: "Sybil"
|
||||
)
|
||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
ipa: ipa_path,
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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")
|
||||
@@ -0,0 +1,48 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios version
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios version
|
||||
```
|
||||
|
||||
Show the version Fastlane will stamp into the next TestFlight archive
|
||||
|
||||
### ios create_ci_signing
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios create_ci_signing
|
||||
```
|
||||
|
||||
Create CI signing certificate/profile and write ignored secret material under build/signing
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Build Sybil and upload it to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
+1
-7
@@ -9,7 +9,7 @@ generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
@@ -24,12 +24,6 @@ run: generate
|
||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||
xcrun simctl launch booted net.buzzert.sybil2
|
||||
|
||||
build-mac: generate
|
||||
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
run-mac: build-mac
|
||||
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
|
||||
@@ -7,31 +7,3 @@ packages:
|
||||
path: Packages/Sybil
|
||||
include:
|
||||
- Apps/Sybil/project.yml
|
||||
- Apps/SybilMacQuickQuestion/project.yml
|
||||
|
||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||
targets:
|
||||
SybilAgentUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- Tests/SybilUITests
|
||||
dependencies:
|
||||
- target: SybilApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TEST_TARGET_NAME: SybilApp
|
||||
SWIFT_VERSION: 6.0
|
||||
schemes:
|
||||
SybilAgentUI:
|
||||
build:
|
||||
targets:
|
||||
SybilApp: all
|
||||
SybilAgentUITests: [test]
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- SybilAgentUITests
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
|
||||
LOG_DIR="$ROOT/.devbox/logs"
|
||||
PID_DIR="$ROOT/.devbox/pids"
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env OPENAI_API_KEY
|
||||
require_env EXA_API_KEY
|
||||
|
||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||
|
||||
export HOST="${HOST:-0.0.0.0}"
|
||||
export PORT="${PORT:-8787}"
|
||||
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
|
||||
|
||||
echo "Installing server dependencies..."
|
||||
npm ci --prefix "$ROOT/server" --no-audit --no-fund
|
||||
|
||||
echo "Installing web dependencies..."
|
||||
npm ci --prefix "$ROOT/web" --no-audit --no-fund
|
||||
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
|
||||
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
|
||||
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
|
||||
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
|
||||
tmux set-environment -t "$SESSION" HOST "$HOST"
|
||||
tmux set-environment -t "$SESSION" PORT "$PORT"
|
||||
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
|
||||
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
|
||||
fi
|
||||
for name in \
|
||||
CHAT_CODEX_TOOL_ENABLED \
|
||||
CHAT_CODEX_REMOTE_HOST \
|
||||
CHAT_CODEX_REMOTE_USER \
|
||||
CHAT_CODEX_REMOTE_PORT \
|
||||
CHAT_CODEX_REMOTE_WORKDIR \
|
||||
CHAT_CODEX_SSH_KEY_PATH \
|
||||
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS \
|
||||
CHAT_SHELL_TOOL_ENABLED \
|
||||
CHAT_SHELL_EXEC_TIMEOUT_MS; do
|
||||
if [[ -n "${!name:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" "$name" "${!name}"
|
||||
fi
|
||||
done
|
||||
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
|
||||
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
|
||||
"npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
echo "Started Sybil in tmux session '$SESSION'."
|
||||
echo "Server: http://127.0.0.1:8787"
|
||||
echo "Web: http://127.0.0.1:5173"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "tmux not found; starting background processes with nohup."
|
||||
|
||||
if [[ -f "$PID_DIR/server.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$PID_DIR/web.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$ROOT/server"
|
||||
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
|
||||
) >"$LOG_DIR/server.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/server.pid"
|
||||
|
||||
(
|
||||
cd "$ROOT/web"
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||
) >"$LOG_DIR/web.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/web.pid"
|
||||
|
||||
echo "Started Sybil background processes."
|
||||
echo "Server log: $LOG_DIR/server.log"
|
||||
echo "Web log: $LOG_DIR/web.log"
|
||||
@@ -1,357 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO="${REPO:-Sybil-2}"
|
||||
WORKLOAD="${WORKLOAD:-sybil-dev}"
|
||||
CLUSTER="${CLUSTER:-viper}"
|
||||
NAMESPACE="${NAMESPACE:-buzzert}"
|
||||
QUOTA="${QUOTA:-research-api}"
|
||||
SECRET="${SECRET:-sybil-dev-env}"
|
||||
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
|
||||
INGRESS_PORT="${INGRESS_PORT:-5173}"
|
||||
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
|
||||
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
|
||||
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
|
||||
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
|
||||
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
|
||||
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
|
||||
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
|
||||
|
||||
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
|
||||
brix create \
|
||||
--project-root "$(dirname "$ROOT")" \
|
||||
--path "$REPO=$ROOT" \
|
||||
--repositories "$REPO" \
|
||||
--include-untracked \
|
||||
--reconcile-policy continuous \
|
||||
-- \
|
||||
"cluster=$CLUSTER" \
|
||||
"namespace=$NAMESPACE" \
|
||||
"name=$WORKLOAD" \
|
||||
"pool.spec.quota=$QUOTA" \
|
||||
"pool.spec.size.resource=cpu" \
|
||||
"pool.spec.size.quantity=1" \
|
||||
"pool.spec.priority=low"
|
||||
|
||||
if [[ "$REPO" != "openai" ]]; then
|
||||
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
|
||||
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
|
||||
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
|
||||
--from-file=id_ed25519="$tmpdir/id_ed25519" \
|
||||
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
fi
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
|
||||
--arg host "$CODEX_SERVICE" \
|
||||
--arg port "$CODEX_SSH_PORT" \
|
||||
--arg workdir "$CODEX_WORKDIR" \
|
||||
--arg keyPath "$CODEX_SSH_KEY_PATH" \
|
||||
'{
|
||||
stringData: {
|
||||
CHAT_CODEX_TOOL_ENABLED: "true",
|
||||
CHAT_CODEX_REMOTE_HOST: $host,
|
||||
CHAT_CODEX_REMOTE_USER: "root",
|
||||
CHAT_CODEX_REMOTE_PORT: $port,
|
||||
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
|
||||
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
|
||||
CHAT_SHELL_TOOL_ENABLED: "true"
|
||||
}
|
||||
}')"
|
||||
|
||||
ensure_codex_deployment() {
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
tolerations:
|
||||
- key: openai.com/team
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: $CODEX_CONTAINER
|
||||
image: $CODEX_IMAGE
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/bin/bash", "-lc"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if ! command -v sshd >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
npm install -g @openai/codex
|
||||
fi
|
||||
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
|
||||
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
|
||||
chmod 700 /root/.ssh
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
{
|
||||
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
|
||||
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
|
||||
fi
|
||||
if [[ -n "\${EXA_API_KEY:-}" ]]; then
|
||||
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
|
||||
fi
|
||||
} >/root/.sybil-codex-env
|
||||
chmod 600 /root/.sybil-codex-env
|
||||
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ -f /root/.sybil-codex-env ]]; then
|
||||
source /root/.sybil-codex-env
|
||||
fi
|
||||
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
|
||||
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
|
||||
fi
|
||||
exec /bin/bash -l
|
||||
SSH_COMMAND
|
||||
chmod 700 /usr/local/bin/sybil-codex-ssh-command
|
||||
ssh-keygen -A
|
||||
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
|
||||
Port $CODEX_SSH_PORT
|
||||
ListenAddress 0.0.0.0
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AcceptEnv OPENAI_API_KEY EXA_API_KEY
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
PermitUserEnvironment no
|
||||
ForceCommand /usr/local/bin/sybil-codex-ssh-command
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
Subsystem sftp internal-sftp
|
||||
PidFile /run/sshd/sybil-codex.pid
|
||||
SSHD_CONFIG
|
||||
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
|
||||
ports:
|
||||
- name: ssh
|
||||
containerPort: $CODEX_SSH_PORT
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: $SECRET
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
volumeMounts:
|
||||
- name: sybil-codex-ssh
|
||||
mountPath: /run/secrets/sybil-codex-ssh
|
||||
readOnly: true
|
||||
- name: sybil-codex-workspace
|
||||
mountPath: /workspace
|
||||
volumes:
|
||||
- name: sybil-codex-ssh
|
||||
secret:
|
||||
secretName: $CODEX_SSH_SECRET
|
||||
defaultMode: 384
|
||||
- name: sybil-codex-workspace
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
ports:
|
||||
- name: ssh
|
||||
port: $CODEX_SSH_PORT
|
||||
targetPort: ssh
|
||||
KUBE_MANIFEST
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
|
||||
}
|
||||
|
||||
ensure_codex_deployment
|
||||
|
||||
ensure_env_from() {
|
||||
local resource="$1"
|
||||
local name="$2"
|
||||
local jsonpath="$3"
|
||||
local patch_path="$4"
|
||||
local existing_env_from
|
||||
|
||||
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
|
||||
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
|
||||
|
||||
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local patch
|
||||
if [[ -z "$existing_env_from" ]]; then
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
|
||||
else
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
|
||||
fi
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
|
||||
}
|
||||
|
||||
ensure_env_from \
|
||||
workload \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
ensure_env_from \
|
||||
pool \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
|
||||
name: "sybil-codex-ssh",
|
||||
secret: {
|
||||
secretName: $secret,
|
||||
defaultMode: 384
|
||||
}
|
||||
}')"
|
||||
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
|
||||
|
||||
patch_pool_codex_mount() {
|
||||
local pool_json containers volumes patch
|
||||
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
|
||||
containers="$(jq \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
(.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName))
|
||||
' <<<"$pool_json")"
|
||||
volumes="$(jq \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
(.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume]
|
||||
' <<<"$pool_json")"
|
||||
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
|
||||
spec: {
|
||||
workers: {
|
||||
template: {
|
||||
spec: {
|
||||
containers: $containers,
|
||||
volumes: $volumes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount() {
|
||||
local workload_json pools patch
|
||||
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
|
||||
pools="$(jq \
|
||||
--arg groupName "$WORKLOAD" \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
.spec.pools
|
||||
| map(
|
||||
if .groupName == $groupName then
|
||||
.template.template.spec.workers.template.spec.containers =
|
||||
((.template.template.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName)))
|
||||
| .template.template.spec.workers.template.spec.volumes =
|
||||
((.template.template.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume])
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' <<<"$workload_json")"
|
||||
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount
|
||||
patch_pool_codex_mount
|
||||
|
||||
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
|
||||
deadline=$((SECONDS + 300))
|
||||
while true; do
|
||||
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
||||
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
|
||||
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
|
||||
|
||||
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
|
||||
|
||||
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
|
||||
|
||||
echo "Devbox update complete: $url"
|
||||
+4
-30
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
Backend API for:
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||
- Personal chat database (chats/messages + LLM call log)
|
||||
|
||||
## Stack
|
||||
@@ -43,16 +43,14 @@ 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`
|
||||
- `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)
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||
- `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 managed-tool providers)
|
||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
||||
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||
@@ -60,7 +58,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 managed-tool providers on the same devbox)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||
|
||||
## API
|
||||
@@ -106,27 +104,3 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
||||
- User accounts + per-device API keys
|
||||
- Postgres support + migrations for prod
|
||||
- Attachments + embeddings + semantic search
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
```dotenv
|
||||
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
|
||||
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
|
||||
```
|
||||
|
||||
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
|
||||
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
|
||||
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
|
||||
`sybil-agent` resolves privately. Supply the key through a private server environment
|
||||
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
|
||||
`ADMIN_TOKEN` authentication for clients.
|
||||
|
||||
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
|
||||
same persistent root conversation across devices. Sybil Agent remains the source of
|
||||
truth for messages and background replies. Sybil stores only the shared,
|
||||
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
|
||||
its migration is applied automatically at startup. The bundled nginx configuration
|
||||
forwards WebSocket upgrades. See `docs/api/rest.md` and
|
||||
`docs/api/streaming-chat.md` at the repository root for the client contracts.
|
||||
|
||||
Generated
-33
@@ -22,12 +22,10 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -878,16 +876,6 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -2253,27 +2241,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -31,12 +31,10 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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");
|
||||
@@ -1,70 +0,0 @@
|
||||
-- External-content indexes avoid storing a second copy of conversation text.
|
||||
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
|
||||
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
|
||||
title, content='Chat', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
-- Hidden system prompts and tool payloads are not searchable conversation text.
|
||||
CREATE VIEW "MessageSearchContent" AS
|
||||
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
|
||||
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
|
||||
content, content='MessageSearchContent', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
|
||||
title, query, answerText, content='Search', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
|
||||
WHEN old.title IS NOT new.title BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
|
||||
WHEN new.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
|
||||
WHEN old.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
|
||||
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
|
||||
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
|
||||
INSERT INTO "MessageSearch" (rowid, content)
|
||||
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
|
||||
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
|
||||
-- Index existing history during migration, then maintain it transactionally.
|
||||
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
|
||||
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
|
||||
|
||||
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE "AgentReadState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"cursor" TEXT NOT NULL DEFAULT '0',
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
@@ -13,7 +13,6 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
gemini
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
@@ -51,8 +50,7 @@ model Chat {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
title String?
|
||||
titleGenerationPending Boolean @default(false)
|
||||
title String?
|
||||
|
||||
initiatedProvider Provider?
|
||||
initiatedModel String?
|
||||
@@ -65,18 +63,11 @@ 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])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
model Message {
|
||||
@@ -215,11 +206,3 @@ model ProjectItem {
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
// Shared personal read position for the external persistent Sybil Agent room.
|
||||
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
|
||||
.transform(value => String(Number(value)));
|
||||
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
|
||||
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
|
||||
|
||||
type AgentOptions = { baseUrl?: string; apiKey?: string };
|
||||
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
|
||||
return result.data;
|
||||
};
|
||||
|
||||
async function readState() {
|
||||
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
|
||||
}
|
||||
|
||||
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
|
||||
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
|
||||
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
|
||||
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
|
||||
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
|
||||
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
|
||||
let data: unknown;
|
||||
try { data = await response.json(); }
|
||||
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
|
||||
return { status: response.status, data };
|
||||
};
|
||||
|
||||
app.get('/v1/agent', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const result = await proxyJson('/api/conversations/root');
|
||||
reply.code(result.status);
|
||||
if (result.status !== 200) return result.data;
|
||||
return { ...result.data as object, websocket: '/v1/agent/ws' };
|
||||
});
|
||||
app.get('/v1/agent/messages', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const query = parse(PageQuery, req.query);
|
||||
const params = new URLSearchParams();
|
||||
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
|
||||
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const body = parse(MessageBody, req.body);
|
||||
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
const receipt = SubmissionReceipt.safeParse(result.data);
|
||||
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
|
||||
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
|
||||
}
|
||||
}
|
||||
reply.code(result.status);
|
||||
return result.data;
|
||||
});
|
||||
app.get('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
return readState();
|
||||
});
|
||||
app.put('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const { cursor } = parse(ReadBody, req.body);
|
||||
// One SQLite statement makes concurrent tabs monotonic. Never write an older
|
||||
// cursor over one saved by another connection between a read and an update.
|
||||
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
|
||||
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
|
||||
return readState();
|
||||
});
|
||||
|
||||
attachAgentSocket(app, baseUrl, apiKey);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
|
||||
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
||||
const cleanup = new Set<() => void>();
|
||||
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
let url: URL;
|
||||
try { url = new URL(request.url || '/', 'http://localhost'); }
|
||||
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
|
||||
// Credentials must never enter URLs and access logs.
|
||||
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
sockets.handleUpgrade(request, socket, head, browser => {
|
||||
let upstream: WebSocket | undefined;
|
||||
let authenticated = false, disposed = false, alive = true;
|
||||
const send = (value: unknown) => {
|
||||
if (browser.readyState !== WebSocket.OPEN) return;
|
||||
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
|
||||
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
|
||||
};
|
||||
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
|
||||
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
|
||||
browser.on('pong', () => { alive = true; });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
|
||||
upstream?.terminate(); browser.terminate();
|
||||
};
|
||||
cleanup.add(dispose);
|
||||
browser.on('close', dispose); browser.on('error', dispose);
|
||||
browser.on('message', (raw, isBinary) => {
|
||||
try {
|
||||
if (isBinary) throw new Error('JSON text frames are required');
|
||||
const frame = JSON.parse(raw.toString());
|
||||
if (!authenticated) {
|
||||
const auth = parse(AuthFrame, frame);
|
||||
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
|
||||
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
|
||||
send(data.toString());
|
||||
});
|
||||
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
|
||||
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
||||
const subscribe = parse(SubscribeFrame, frame);
|
||||
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
|
||||
upstream.send(JSON.stringify(subscribe));
|
||||
} catch (error) {
|
||||
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
|
||||
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
app.server.on('upgrade', upgrade);
|
||||
app.addHook('preClose', async () => {
|
||||
app.server.off('upgrade', upgrade);
|
||||
for (const stop of cleanup) stop();
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
sockets.close();
|
||||
});
|
||||
}
|
||||
+1
-13
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
},
|
||||
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||
z.enum(["exa", "searxng"]).default("exa")
|
||||
);
|
||||
|
||||
const BooleanFlagSchema = z.preprocess((value) => {
|
||||
@@ -66,14 +66,10 @@ 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,
|
||||
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
|
||||
SYBIL_AGENT_API_KEY: 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,
|
||||
@@ -103,14 +99,6 @@ 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",
|
||||
|
||||
+1
-3
@@ -6,7 +6,6 @@ import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -27,7 +26,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
@@ -87,7 +86,6 @@ app.addHook("onClose", async () => {
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
await registerAgentRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -508,33 +507,11 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,45 +28,6 @@ import type { ChatMessage } from "../types.js";
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
|
||||
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
|
||||
|
||||
function readMaxTokens(value: unknown) {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
function getModelInfoMaxTokens(modelInfo: any) {
|
||||
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
|
||||
}
|
||||
|
||||
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
|
||||
if (params.maxTokens) return params.maxTokens;
|
||||
|
||||
const cached = modelMaxTokensCache.get(params.model);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
|
||||
|
||||
try {
|
||||
const retrieve = params.client?.models?.retrieve;
|
||||
if (typeof retrieve === "function") {
|
||||
const modelInfo = await retrieve.call(params.client.models, params.model);
|
||||
const maxTokens = getModelInfoMaxTokens(modelInfo);
|
||||
if (maxTokens) {
|
||||
modelMaxTokensCache.set(params.model, {
|
||||
maxTokens,
|
||||
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
|
||||
});
|
||||
return maxTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
|
||||
}
|
||||
|
||||
return DEFAULT_ANTHROPIC_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function toTools(tools: any[]) {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
@@ -199,12 +160,11 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
|
||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
} as any);
|
||||
@@ -232,7 +192,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
@@ -288,7 +248,6 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
||||
|
||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -300,7 +259,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
stream: true,
|
||||
@@ -356,7 +315,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
|
||||
@@ -5,11 +5,10 @@ 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, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import type { ChatMessage, Provider } from "./types.js";
|
||||
|
||||
type ProviderAdapterParams = {
|
||||
@@ -28,7 +27,7 @@ export type ProviderChatAdapter = {
|
||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||
|
||||
type ChatProtocol = {
|
||||
id: ChatProtocolId;
|
||||
@@ -40,7 +39,6 @@ type ModelCatalogSpec = {
|
||||
enabled?: () => boolean;
|
||||
fetchModels(client: any): Promise<string[]>;
|
||||
fallbackModels?: () => string[];
|
||||
sortModels?: (models: string[]) => string[];
|
||||
};
|
||||
|
||||
type ProviderBackendSpec = {
|
||||
@@ -63,12 +61,6 @@ const messagesProtocol: ChatProtocol = {
|
||||
stream: streamWithMessagesApi,
|
||||
};
|
||||
|
||||
const geminiProtocol: ChatProtocol = {
|
||||
id: "gemini",
|
||||
complete: completeWithGeminiApi,
|
||||
stream: streamWithGeminiApi,
|
||||
};
|
||||
|
||||
const responsesProtocol: ChatProtocol = {
|
||||
id: "responses",
|
||||
complete: completeWithResponsesApi,
|
||||
@@ -85,48 +77,13 @@ 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;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return id === "chat-latest" || /^(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;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||
@@ -203,29 +160,6 @@ 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,
|
||||
@@ -275,8 +209,7 @@ export function listModelCatalogProviders(): Provider[] {
|
||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||
const spec = backendSpecs[provider].modelCatalog;
|
||||
if (!spec) return [];
|
||||
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
return spec.sortModels ? spec.sortModels(models) : models;
|
||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
|
||||
@@ -6,7 +6,6 @@ const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
@@ -14,7 +13,6 @@ 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 Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export function openaiClient() {
|
||||
@@ -13,14 +13,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
||||
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
||||
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
||||
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
||||
|
||||
export type QuickQuestionRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
question: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export function buildQuickQuestionMultiplexRequest({
|
||||
question,
|
||||
...request
|
||||
}: QuickQuestionRequest): MultiplexRequest {
|
||||
return {
|
||||
...request,
|
||||
persist: false,
|
||||
messages: [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -119,12 +119,7 @@ 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,
|
||||
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||
},
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -33,7 +33,6 @@ export type ChatMessage = {
|
||||
export type MultiplexRequest = {
|
||||
chatId?: string;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
|
||||
+44
-361
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -11,17 +10,14 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
||||
import { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
import { exaClient } from "./search/exa.js";
|
||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||
import { searchWorkspace } from "./workspace-search.js";
|
||||
import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "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 = {
|
||||
@@ -92,13 +88,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[], clientRequestId?: string) {
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
@@ -113,21 +109,14 @@ 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, 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,
|
||||
};
|
||||
}),
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,7 +169,6 @@ 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),
|
||||
@@ -198,25 +186,8 @@ 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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const QuickQuestionStreamBody = z.object({
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
question: z.string().trim().min(1),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||
if (!attachments?.length) return metadata as any;
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
@@ -331,21 +302,6 @@ 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.";
|
||||
@@ -443,8 +399,6 @@ 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";
|
||||
|
||||
@@ -457,8 +411,6 @@ const starredProjectItemsSelect = {
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
titleGenerationPending: true,
|
||||
parentChatId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
@@ -539,29 +491,6 @@ 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;
|
||||
@@ -600,7 +529,11 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
listRecentChatsWithRoots(),
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
@@ -621,7 +554,6 @@ 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?.();
|
||||
|
||||
@@ -656,24 +588,10 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
||||
return { event: ev.type, data: ev };
|
||||
}
|
||||
|
||||
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||
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 {
|
||||
@@ -693,46 +611,11 @@ function executeActiveChatStream(chatId: string, body: z.infer<typeof Completion
|
||||
} catch (err) {
|
||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
} finally {
|
||||
clearActiveChatStream(chatId, stream);
|
||||
activeChatStreams.delete(chatId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||
@@ -928,43 +811,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/v1/workspace-items", async (req) => {
|
||||
requireAdmin(req);
|
||||
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
|
||||
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
|
||||
if (parsed.data.q) {
|
||||
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
|
||||
select: {
|
||||
...chatSummarySelect,
|
||||
parentChat: { select: { projectItems: starredProjectItemsSelect } },
|
||||
},
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
const summaries = new Map<string, Record<string, unknown>>([
|
||||
...chats.map(({ parentChat, ...chat }) => [
|
||||
`chat:${chat.id}`,
|
||||
// Search omits unmatched roots, but sidebar fork stars belong to the root.
|
||||
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
|
||||
] as const),
|
||||
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
|
||||
]);
|
||||
const items = hits.flatMap((hit) => {
|
||||
const summary = summaries.get(`${hit.type}:${hit.id}`);
|
||||
return summary ? [{ ...summary, ...hit }] : [];
|
||||
});
|
||||
return { items, hasMore };
|
||||
}
|
||||
return { items: await listWorkspaceItems() };
|
||||
});
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const chats = await listRecentChatsWithRoots();
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
|
||||
@@ -1023,80 +879,6 @@ 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() });
|
||||
@@ -1109,10 +891,7 @@ 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;
|
||||
data.titleGenerationPending = false;
|
||||
}
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||
|
||||
@@ -1153,30 +932,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
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 suggestedRaw = await generateChatTitle(body.content);
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: {
|
||||
id: body.chatId,
|
||||
title: existing.title,
|
||||
titleGenerationPending: existing.titleGenerationPending,
|
||||
},
|
||||
data: { title, titleGenerationPending: false },
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
data: { title },
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
@@ -1192,46 +956,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
req.log.info({ chatId }, "delete chat requested");
|
||||
|
||||
const target = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { parentChatId: true },
|
||||
});
|
||||
if (!target) {
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
req.log.warn({ chatId }, "delete chat target not found");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
app.get("/v1/searches", async (req) => {
|
||||
@@ -1521,7 +1253,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
@@ -1607,28 +1339,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
// One-shot, non-persistent Quick Question SSE endpoint.
|
||||
app.post("/v1/quick-questions/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
|
||||
const parsed = QuickQuestionStreamBody.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
|
||||
// General chat completion SSE endpoint.
|
||||
// Streaming SSE endpoint.
|
||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
|
||||
@@ -1636,51 +1347,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// Ensure the chat exists and identify its family before reserving a stream.
|
||||
let chatFamilyRootId: string | null = null;
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
const exists = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: { id: true, parentChatId: true },
|
||||
});
|
||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
||||
}
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.persist !== false && body.chatId) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
if (body.persist !== false && body.chatId) {
|
||||
const activeStream = activeChatStreams.get(body.chatId);
|
||||
if (activeStream) {
|
||||
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
||||
return streamActiveRun(req, reply, activeStream);
|
||||
}
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./db.js";
|
||||
|
||||
export const WORKSPACE_SEARCH_LIMIT = 100;
|
||||
|
||||
// Treat input as literal Unicode words, never FTS operators/column expressions.
|
||||
// Prefix matching supports type-ahead, including short terms such as "AI".
|
||||
export function workspaceSearchExpression(query: string): string | null {
|
||||
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
|
||||
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
|
||||
}
|
||||
|
||||
export type WorkspaceSearchHit = {
|
||||
type: "chat" | "search";
|
||||
id: string;
|
||||
searchSnippet: string | null;
|
||||
};
|
||||
|
||||
export function workspaceSearchSQL(expression: string) {
|
||||
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
|
||||
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
|
||||
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
|
||||
return Prisma.sql`
|
||||
WITH chat_hits AS (
|
||||
SELECT c.id, NULL AS message_rowid
|
||||
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
|
||||
WHERE "ChatSearch" MATCH ${expression}
|
||||
UNION ALL
|
||||
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
|
||||
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
GROUP BY m.chatId
|
||||
), matches AS (
|
||||
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
|
||||
NULL AS search_rowid
|
||||
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
|
||||
GROUP BY c.id
|
||||
UNION ALL
|
||||
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
|
||||
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
), limited AS MATERIALIZED (
|
||||
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
|
||||
), message_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "MessageSearch"
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT message_rowid FROM limited)
|
||||
), search_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "SavedSearchSearch"
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT search_rowid FROM limited)
|
||||
)
|
||||
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
|
||||
FROM limited
|
||||
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
|
||||
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
|
||||
ORDER BY updatedAt DESC, type, id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function searchWorkspace(query: string) {
|
||||
const expression = workspaceSearchExpression(query);
|
||||
if (!expression) return { hits: [], hasMore: false };
|
||||
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
|
||||
// only for the bounded result set, never fetch full transcripts into JS.
|
||||
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||||
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { createConnection } from 'node:net';
|
||||
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';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
});
|
||||
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
|
||||
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
|
||||
]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
|
||||
const privateKey = 'sybil-agent-private-key-for-test';
|
||||
const requests: { method?: string; url?: string; body: any }[] = [];
|
||||
const accepted = new Map<string, number>();
|
||||
let upstreamReply: { status: number; body: string } | undefined;
|
||||
let connections = 0;
|
||||
const subscriptions: any[] = [];
|
||||
const upstream = createServer(async (req, res) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||
requests.push({ method: req.method, url: req.url, body });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
|
||||
if (req.method === 'POST') {
|
||||
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
|
||||
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
|
||||
} else if (req.url?.includes('/messages')) {
|
||||
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
|
||||
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
|
||||
});
|
||||
const sourceSockets = new WebSocketServer({ noServer: true });
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
assert.equal(req.url, '/api/ws');
|
||||
connections++;
|
||||
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
||||
ws.on('message', raw => {
|
||||
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
|
||||
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
|
||||
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
|
||||
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
|
||||
});
|
||||
});
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address() as { port: number };
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const address = app.server.address() as { port: number };
|
||||
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
for (const socket of sourceSockets.clients) socket.terminate();
|
||||
sourceSockets.close();
|
||||
await new Promise<void>(resolve => upstream.close(() => resolve()));
|
||||
await prisma.$disconnect();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function eventually(check: () => boolean) {
|
||||
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
|
||||
assert.fail('Expected condition was not reached');
|
||||
}
|
||||
|
||||
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
|
||||
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
|
||||
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
|
||||
assert.equal(descriptor.statusCode, 200);
|
||||
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
|
||||
assert.ok(!descriptor.body.includes(privateKey));
|
||||
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
|
||||
assert.equal(page.json().messages[0].role, 'assistant');
|
||||
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
|
||||
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
|
||||
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
|
||||
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
|
||||
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
|
||||
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
|
||||
assert.equal(initial.json().cursor, '0');
|
||||
assert.equal(initial.headers['cache-control'], 'no-store');
|
||||
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
|
||||
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
|
||||
await prisma.$disconnect();
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
|
||||
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
|
||||
try {
|
||||
upstreamReply = { status: 200, body: '{truncated' };
|
||||
for (const url of ['/v1/agent', '/v1/agent/messages']) {
|
||||
const response = await app.inject({ url, headers: auth });
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
|
||||
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
|
||||
upstreamReply = { status: 202, body };
|
||||
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(response.statusCode, 502, body);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
} finally { upstreamReply = undefined; }
|
||||
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(retry.statusCode, 202);
|
||||
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
|
||||
});
|
||||
|
||||
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
|
||||
const before = connections;
|
||||
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
let response = '';
|
||||
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
|
||||
});
|
||||
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
|
||||
socket.on('data', chunk => { response += chunk.toString(); });
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => { socket.destroy(); resolve(response); });
|
||||
});
|
||||
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
|
||||
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
|
||||
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
|
||||
assert.equal(connections, before);
|
||||
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
|
||||
const before = connections;
|
||||
const denied = new WebSocket(wsUrl); await once(denied, 'open');
|
||||
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
|
||||
const [code] = await once(denied, 'close');
|
||||
assert.equal(code, 4401); assert.equal(connections, before);
|
||||
const browser = new WebSocket(wsUrl);
|
||||
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'activity'));
|
||||
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
browser.close(); await once(browser, 'close');
|
||||
await eventually(() => sourceSockets.clients.size === 0);
|
||||
});
|
||||
|
||||
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try {
|
||||
const browser = new WebSocket(wsUrl); const frames: any[] = [];
|
||||
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'ready'));
|
||||
browser.close(); await once(browser, 'close');
|
||||
} finally { env.ADMIN_TOKEN = saved; }
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } 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")}`;
|
||||
writeFileSync(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);
|
||||
});
|
||||
@@ -140,94 +140,6 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||
});
|
||||
|
||||
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
|
||||
let requestBody: any = null;
|
||||
let retrievedModel: string | null = null;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async (model: string) => {
|
||||
retrievedModel = model;
|
||||
return { id: model, max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-max-default-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
});
|
||||
|
||||
assert.equal(retrievedModel, "claude-max-default-test");
|
||||
assert.equal(requestBody?.max_tokens, 128000);
|
||||
assert.equal(result.text, "Done");
|
||||
});
|
||||
|
||||
test("Messages API preserves explicit maxTokens", async () => {
|
||||
let requestBody: any = null;
|
||||
let didRetrieveModel = false;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async () => {
|
||||
didRetrieveModel = true;
|
||||
return { max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-explicit-max-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
maxTokens: 4096,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(didRetrieveModel, false);
|
||||
assert.equal(requestBody?.max_tokens, 4096);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("fetch_url sends browser-like navigation headers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { env } from "../src/env.js";
|
||||
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||
|
||||
for (const { name, listedModels, expectedModels } of [
|
||||
{
|
||||
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||
listedModels: [
|
||||
"gpt-4.1-mini",
|
||||
"chat-latest",
|
||||
"o3",
|
||||
"chatgpt-4o-latest",
|
||||
"chat-latest",
|
||||
"text-embedding-3-small",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime",
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-tts",
|
||||
"gpt-image-1",
|
||||
"chatgpt-image-latest",
|
||||
"gpt-4o-search-preview",
|
||||
"computer-use-preview",
|
||||
"sora-2",
|
||||
],
|
||||
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||
listedModels: ["gpt-4.1-mini"],
|
||||
expectedModels: ["gpt-4.1-mini"],
|
||||
},
|
||||
]) {
|
||||
test(name, async (t) => {
|
||||
const originalKey = env.OPENAI_API_KEY;
|
||||
env.OPENAI_API_KEY = "test-openai-key";
|
||||
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.equal(url, "https://api.openai.com/v1/models");
|
||||
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||
});
|
||||
}
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
@@ -71,12 +27,6 @@ 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,10 +5,8 @@ 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.equal(toPrismaProvider("gemini"), "gemini");
|
||||
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||
initiatedProvider: "hermes-agent",
|
||||
lastUsedProvider: "gemini",
|
||||
lastUsedProvider: "xai",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildQuickQuestionMultiplexRequest,
|
||||
QUICK_QUESTION_SYSTEM_PROMPT,
|
||||
} from "../src/llm/quick-question.js";
|
||||
|
||||
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
||||
const request = buildQuickQuestionMultiplexRequest({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
question: "How do I reset my password?",
|
||||
enabledTools: ["web_search"],
|
||||
userLocation: "Los Angeles, CA",
|
||||
});
|
||||
|
||||
assert.equal(request.persist, false);
|
||||
assert.equal(request.chatId, undefined);
|
||||
assert.deepEqual(request.messages, [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: "How do I reset my password?" },
|
||||
]);
|
||||
assert.deepEqual(request.enabledTools, ["web_search"]);
|
||||
assert.equal(request.userLocation, "Los Angeles, CA");
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } 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";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
|
||||
const migration = "20261007000000_add_workspace_full_text_search";
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
|
||||
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
|
||||
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
|
||||
}
|
||||
function migrate() {
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
|
||||
cwd: serverRoot, env: process.env, stdio: "pipe",
|
||||
});
|
||||
}
|
||||
migrate();
|
||||
const { prisma } = await import("../src/db.js");
|
||||
const oldChat = await prisma.chat.create({ data: {
|
||||
title: "Old constellation notes",
|
||||
messages: { create: [
|
||||
{ role: "user", content: "We photographed a nebula before the migration." },
|
||||
{ role: "system", content: "secretmigrationprompt" },
|
||||
] },
|
||||
} });
|
||||
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
|
||||
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
|
||||
migrate();
|
||||
|
||||
const { registerRoutes } = await import("../src/routes.js");
|
||||
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
async function search(query: string) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
await prisma.chat.deleteMany();
|
||||
await prisma.search.deleteMany();
|
||||
});
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration indexes existing titles, messages, queries, and answers", async () => {
|
||||
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
|
||||
assert.equal((await search("constellation")).items.length, 2);
|
||||
assert.equal((await search("secretmigrationprompt")).items.length, 0);
|
||||
});
|
||||
|
||||
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "Travel notes", updatedAt: new Date("2000-01-01"),
|
||||
messages: { create: [
|
||||
{ role: "user", content: "Let's discuss café planning in Zürich" },
|
||||
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
|
||||
{ role: "system", content: "hiddensystemvalue" },
|
||||
{ role: "tool", content: "hiddentoolvalue" },
|
||||
] },
|
||||
} });
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
|
||||
const result = await search("CAFE plan");
|
||||
assert.equal(result.hasMore, false);
|
||||
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
|
||||
assert.match(result.items[0].searchSnippet, /café planning/i);
|
||||
assert.equal(result.items[0].messages, undefined);
|
||||
assert.equal((await search("zuri")).items.length, 1);
|
||||
assert.equal((await search("hiddensystemvalue")).items.length, 0);
|
||||
assert.equal((await search("hiddentoolvalue")).items.length, 0);
|
||||
});
|
||||
|
||||
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
|
||||
}, include: { messages: true } });
|
||||
const messageID = chat.messages[0].id;
|
||||
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
|
||||
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
|
||||
assert.equal((await search("original")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 0);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
|
||||
throw new Error("rollback");
|
||||
}));
|
||||
assert.equal((await search("rolledbackvalue")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
|
||||
assert.equal(fork.statusCode, 200, fork.body);
|
||||
assert.equal((await search("replacementbody")).items.length, 2);
|
||||
await prisma.message.delete({ where: { id: messageID } });
|
||||
const onlyFork = (await search("replacementbody")).items;
|
||||
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
|
||||
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
|
||||
assert.equal(starResponse.statusCode, 200, starResponse.body);
|
||||
const starredFork = (await search("replacementbody")).items[0];
|
||||
assert.equal(starredFork.starred, true);
|
||||
assert.ok(starredFork.starredAt);
|
||||
assert.equal(starredFork.parentChat, undefined);
|
||||
await prisma.chat.delete({ where: { id: chat.id } });
|
||||
assert.equal((await search("replacement")).items.length, 0);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("saved searches match query and answer, and update/delete their index", async () => {
|
||||
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
|
||||
assert.equal((await search("ai des")).items[0].type, "search");
|
||||
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
|
||||
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
|
||||
assert.equal((await search("nebula")).items.length, 0);
|
||||
assert.equal((await search("design")).items.length, 0);
|
||||
assert.equal((await search("Bookmarks")).items.length, 0);
|
||||
assert.equal((await search("newanswer")).items.length, 1);
|
||||
await prisma.search.delete({ where: { id: saved.id } });
|
||||
assert.equal((await search("new")).items.length, 0);
|
||||
});
|
||||
|
||||
test("literal queries, validation, blank queries, and bounded snippets", async () => {
|
||||
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
|
||||
assert.equal((await search('"AI" OR safety*')).items.length, 1);
|
||||
assert.equal((await search("AI OR other")).items.length, 0);
|
||||
assert.equal((await search("AI")).items.length, 1);
|
||||
assert.equal((await search("*** : () --")).items.length, 0);
|
||||
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
|
||||
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.deepEqual(await search(" "), recent);
|
||||
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
|
||||
assert.equal(response.statusCode, 400, response.body);
|
||||
}
|
||||
});
|
||||
|
||||
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
|
||||
const date = new Date("2026-01-01");
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
|
||||
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
|
||||
const result = await search("common");
|
||||
assert.equal(result.items.length, 100);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.items[0].id, "saved");
|
||||
assert.equal(result.items[1].id, "chat-000");
|
||||
assert.equal(result.items.at(-1).id, "chat-098");
|
||||
});
|
||||
|
||||
test("query plan uses all three FTS indexes", async () => {
|
||||
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
|
||||
}
|
||||
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
|
||||
});
|
||||
|
||||
test("vacuum preserves external-content index row identities", async () => {
|
||||
const removed = await prisma.chat.create({ data: { title: "removed" } });
|
||||
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
|
||||
await prisma.chat.delete({ where: { id: removed.id } });
|
||||
await prisma.$executeRaw`VACUUM`;
|
||||
assert.equal((await search("kepttitle")).items[0].id, kept.id);
|
||||
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
|
||||
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
+22
-45
@@ -42,13 +42,12 @@ type ToolLogMetadata = {
|
||||
resultPreview?: string | null;
|
||||
};
|
||||
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
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"],
|
||||
};
|
||||
|
||||
@@ -56,7 +55,6 @@ 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) {
|
||||
@@ -81,7 +79,6 @@ 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 "";
|
||||
}
|
||||
@@ -127,27 +124,21 @@ 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: starOwner.starred,
|
||||
starredAt: starOwner.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: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +266,6 @@ async function main() {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
gemini: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||
@@ -985,11 +975,11 @@ async function main() {
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
||||
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
||||
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
||||
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;
|
||||
|
||||
const chatId = chat.id;
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
@@ -999,8 +989,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1057,8 +1045,6 @@ 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,
|
||||
@@ -1078,13 +1064,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))
|
||||
@@ -1402,8 +1388,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
@@ -1417,16 +1401,12 @@ 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 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);
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
@@ -1437,8 +1417,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1449,8 +1427,7 @@ async function main() {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
||||
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
|
||||
+1
-5
@@ -1,4 +1,4 @@
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -13,8 +13,6 @@ export type ModelCatalogResponse = {
|
||||
export type ChatSummary = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
@@ -70,8 +68,6 @@ export type ToolCallEvent = {
|
||||
export type ChatDetail = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentChatId: string | null;
|
||||
titleGenerationPending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
|
||||
@@ -34,13 +34,6 @@ Default dev URL: `http://localhost:5173`
|
||||
## UI
|
||||
|
||||
- Left panel: mixed list of chat conversations and Exa searches.
|
||||
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
|
||||
conversation messages, and web-search queries/answers. It waits 300 ms while
|
||||
typing and cancels superseded requests. Results include a single-line matching excerpt
|
||||
with matching words in bold and middle truncation
|
||||
(or “Title match” for title-only chat matches), with up to 100 newest matches.
|
||||
Clear the field or press Escape to return to recent items. Matching uses word
|
||||
prefixes, so `plan` also finds `planning`.
|
||||
- Right panel:
|
||||
- Chat mode: transcript + provider/model controls.
|
||||
- Search mode: top AI answer block + Google-style Exa results view.
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
+2
-16
@@ -3,24 +3,10 @@ self.addEventListener("install", () => {
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
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.
|
||||
}
|
||||
})
|
||||
);
|
||||
})()
|
||||
);
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
|
||||
+504
-1287
File diff suppressed because it is too large
Load Diff
@@ -1,457 +0,0 @@
|
||||
.agent-room {
|
||||
height:100%;
|
||||
min-height:0;
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
background:radial-gradient(ellipse at 80% 0%,hsl(266 80% 30%/.12),transparent 42%),hsl(237 43% 6%/.72);
|
||||
color:hsl(258 36% 96%);
|
||||
}
|
||||
|
||||
.agent-room__body {
|
||||
position:relative;
|
||||
display:flex;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__scroll {
|
||||
width:100%;
|
||||
overflow:auto;
|
||||
overscroll-behavior:contain;
|
||||
scrollbar-gutter:stable;
|
||||
outline:none;
|
||||
overflow-anchor:none;
|
||||
}
|
||||
|
||||
.agent-room__scroll:focus-visible {
|
||||
outline:1px solid hsl(264 80% 74%/.48);
|
||||
outline-offset:-2px;
|
||||
}
|
||||
|
||||
.agent-room__timeline {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:flex-end;
|
||||
gap:15px;
|
||||
max-width:848px;
|
||||
min-height:100%;
|
||||
margin:0 auto;
|
||||
padding:24px 30px 22px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
.agent-room__entry {
|
||||
flex-shrink:0;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__day {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:18px;
|
||||
margin:17px 0 24px;
|
||||
color:hsl(255 19% 64%);
|
||||
font-size:10px;
|
||||
letter-spacing:.05em;
|
||||
}
|
||||
|
||||
.agent-room__day:before,.agent-room__day:after {
|
||||
content:"";
|
||||
height:1px;
|
||||
width:48px;
|
||||
background:linear-gradient(90deg,transparent,hsl(260 48% 65%/.19));
|
||||
}
|
||||
|
||||
.agent-room__day:after {
|
||||
transform:rotate(180deg);
|
||||
}
|
||||
|
||||
.agent-room__message {
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:9px;
|
||||
min-width:0;
|
||||
max-width:89%;
|
||||
}
|
||||
|
||||
.agent-room__message[data-animation="enter"] {
|
||||
animation:agent-room-message-enter 240ms cubic-bezier(.2,.7,.25,1) both;
|
||||
}
|
||||
|
||||
.agent-room__message.is-own {
|
||||
margin-left:auto;
|
||||
justify-content:flex-end;
|
||||
max-width:78%;
|
||||
}
|
||||
|
||||
.agent-room__message-avatar {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
align-self:flex-end;
|
||||
width:26px;
|
||||
height:26px;
|
||||
margin-bottom:23px;
|
||||
border:1px solid hsl(267 75% 73%/.2);
|
||||
border-radius:9px;
|
||||
background:hsl(261 52% 20%/.56);
|
||||
color:hsl(269 72% 80%);
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.agent-room__message-avatar img {
|
||||
display:block;
|
||||
width:100%;
|
||||
height:100%;
|
||||
object-fit:cover;
|
||||
}
|
||||
|
||||
.agent-room__message-content {
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.agent-room__plain-message {
|
||||
margin:0;
|
||||
white-space:pre-wrap;
|
||||
}
|
||||
|
||||
.agent-room__message-meta {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
margin:5px 3px 0;
|
||||
color:hsl(253 18% 55%);
|
||||
font-size:9px;
|
||||
line-height:1.4;
|
||||
}
|
||||
|
||||
.agent-room__message.is-own .agent-room__message-meta {
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
.agent-room__message-meta>time:before {
|
||||
content:"·";
|
||||
margin-right:7px;
|
||||
opacity:.6;
|
||||
}
|
||||
|
||||
.agent-room__delivery {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
color:hsl(272 52% 77%);
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.agent-room__delivery.is-error {
|
||||
color:hsl(349 70% 78%);
|
||||
}
|
||||
|
||||
.agent-room__delivery button {
|
||||
border:0;
|
||||
padding:0 0 0 3px;
|
||||
background:transparent;
|
||||
color:inherit;
|
||||
font:inherit;
|
||||
text-decoration:underline;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room .md-content p {
|
||||
margin-top:.55em;
|
||||
margin-bottom:.55em;
|
||||
}
|
||||
|
||||
.agent-room .md-content>:first-child {
|
||||
margin-top:0;
|
||||
}
|
||||
|
||||
.agent-room .md-content>:last-child {
|
||||
margin-bottom:0;
|
||||
}
|
||||
|
||||
.agent-room .md-content pre {
|
||||
max-width:100%;
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.agent-room .md-content h1 {
|
||||
font-size:1.25em;
|
||||
}
|
||||
|
||||
.agent-room .md-content h2 {
|
||||
font-size:1.15em;
|
||||
}
|
||||
|
||||
.agent-room .md-content h3 {
|
||||
font-size:1.05em;
|
||||
}
|
||||
|
||||
.agent-room .md-content a {
|
||||
color:hsl(189 79% 75%);
|
||||
}
|
||||
|
||||
.agent-room__typing,.agent-room__waiting {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
flex:none;
|
||||
min-height:20px;
|
||||
margin-left:36px;
|
||||
color:hsl(254 22% 63%);
|
||||
font-size:11px;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:3px;
|
||||
padding:6px 8px;
|
||||
border-radius:9px;
|
||||
background:hsl(258 43% 22%/.42);
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i {
|
||||
width:3px;
|
||||
height:3px;
|
||||
border-radius:50%;
|
||||
background:hsl(268 72% 78%);
|
||||
animation:agent-room-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i:nth-child(2) {
|
||||
animation-delay:.16s;
|
||||
}
|
||||
|
||||
.agent-room__typing-dots i:nth-child(3) {
|
||||
animation-delay:.32s;
|
||||
}
|
||||
|
||||
.agent-room__worker-count {
|
||||
padding:2px 5px;
|
||||
border-radius:5px;
|
||||
background:hsl(265 58% 27%/.3);
|
||||
color:hsl(268 58% 76%);
|
||||
font-size:9px;
|
||||
}
|
||||
|
||||
.agent-room__jump {
|
||||
position:absolute;
|
||||
z-index:2;
|
||||
left:50%;
|
||||
bottom:14px;
|
||||
transform:translateX(-50%);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
white-space:nowrap;
|
||||
padding:9px 14px;
|
||||
border:1px solid hsl(266 76% 72%/.43);
|
||||
border-radius:99px;
|
||||
background:hsl(256 46% 20%/.96);
|
||||
color:hsl(263 60% 93%);
|
||||
box-shadow:0 5px 24px hsl(240 70% 2%/.5);
|
||||
font-size:11px;
|
||||
cursor:pointer;
|
||||
backdrop-filter:blur(14px);
|
||||
}
|
||||
|
||||
.agent-room__jump:hover {
|
||||
background:hsl(261 48% 27%);
|
||||
}
|
||||
|
||||
.agent-room__composer-area {
|
||||
flex:none;
|
||||
border-top:1px solid hsl(253 40% 40%/.14);
|
||||
padding:16px 30px max(13px,var(--safe-area-bottom));
|
||||
background:linear-gradient(180deg,hsl(240 41% 7%/.72),hsl(240 43% 7%/.97));
|
||||
}
|
||||
|
||||
.agent-room__composer-inner {
|
||||
max-width:788px;
|
||||
margin:0 auto;
|
||||
}
|
||||
|
||||
.agent-room__composer {
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:10px;
|
||||
padding:7px 8px 7px 5px;
|
||||
border:1px solid hsl(263 51% 58%/.3);
|
||||
border-radius:17px;
|
||||
background:hsl(247 35% 13%/.9);
|
||||
box-shadow:inset 0 1px 0 hsl(266 80% 92%/.04),0 6px 24px hsl(239 80% 2%/.12);
|
||||
transition:border-color .15s,box-shadow .15s;
|
||||
}
|
||||
|
||||
.agent-room__composer:focus-within {
|
||||
border-color:hsl(265 74% 69%/.65);
|
||||
box-shadow:0 0 0 3px hsl(266 69% 55%/.08);
|
||||
}
|
||||
|
||||
.agent-room__composer textarea {
|
||||
display:block;
|
||||
flex:1;
|
||||
min-width:0;
|
||||
min-height:46px;
|
||||
max-height:156px;
|
||||
margin:0;
|
||||
resize:none;
|
||||
overflow:auto;
|
||||
border:0;
|
||||
outline:0;
|
||||
background:transparent;
|
||||
padding:13px 11px;
|
||||
font-size:14px;
|
||||
line-height:20px;
|
||||
color:hsl(259 44% 96%);
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
.agent-room__composer textarea::placeholder {
|
||||
color:hsl(255 23% 53%);
|
||||
}
|
||||
|
||||
.agent-room__send {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
width:38px;
|
||||
height:38px;
|
||||
margin-bottom:4px;
|
||||
border:1px solid hsl(265 86% 79%/.32);
|
||||
border-radius:11px;
|
||||
background:linear-gradient(140deg,hsl(264 80% 59%),hsl(270 75% 49%));
|
||||
color:hsl(265 65% 98%);
|
||||
box-shadow:0 3px 12px hsl(264 75% 45%/.22);
|
||||
cursor:pointer;
|
||||
transition:filter .15s,opacity .15s;
|
||||
}
|
||||
|
||||
.agent-room__send:hover:not(:disabled) {
|
||||
filter:brightness(1.17);
|
||||
}
|
||||
|
||||
.agent-room__send:disabled {
|
||||
opacity:.34;
|
||||
box-shadow:none;
|
||||
cursor:default;
|
||||
}
|
||||
|
||||
.agent-room__error {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
margin-bottom:11px;
|
||||
padding:10px 12px;
|
||||
border:1px solid hsl(350 62% 62%/.2);
|
||||
border-radius:11px;
|
||||
background:hsl(340 45% 22%/.22);
|
||||
color:hsl(349 70% 85%);
|
||||
font-size:11px;
|
||||
}
|
||||
|
||||
.agent-room__error>span {
|
||||
min-width:0;
|
||||
flex:1;
|
||||
}
|
||||
|
||||
.agent-room__error>svg {
|
||||
flex:none;
|
||||
}
|
||||
|
||||
.agent-room__error button {
|
||||
flex:none;
|
||||
border:1px solid hsl(350 57% 66%/.28);
|
||||
border-radius:6px;
|
||||
padding:4px 8px;
|
||||
color:hsl(349 66% 90%);
|
||||
font-size:10px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room__empty {
|
||||
flex:none;
|
||||
margin:auto;
|
||||
max-width:360px;
|
||||
padding:48px 15px;
|
||||
text-align:center;
|
||||
color:hsl(254 25% 65%);
|
||||
}
|
||||
|
||||
.agent-room button:focus-visible,.agent-room summary:focus-visible {
|
||||
outline:2px solid hsl(188 82% 71%);
|
||||
outline-offset:3px;
|
||||
}
|
||||
|
||||
@keyframes agent-room-dot {
|
||||
0%,70%,100% {
|
||||
opacity:.35;
|
||||
transform:translateY(0);
|
||||
}
|
||||
35% {
|
||||
opacity:1;
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-room-message-enter {
|
||||
from {
|
||||
opacity:0;
|
||||
transform:translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:767px) {
|
||||
.agent-room__timeline {
|
||||
padding:17px 14px 20px;
|
||||
gap:14px;
|
||||
}
|
||||
.agent-room__message {
|
||||
max-width:96%;
|
||||
gap:7px;
|
||||
}
|
||||
.agent-room__message.is-own {
|
||||
max-width:87%;
|
||||
}
|
||||
.agent-room__message-avatar {
|
||||
width:23px;
|
||||
height:23px;
|
||||
border-radius:8px;
|
||||
}
|
||||
.agent-room__typing,.agent-room__waiting {
|
||||
margin-left:30px;
|
||||
font-size:10px;
|
||||
}
|
||||
.agent-room__composer-area {
|
||||
padding:12px 13px max(11px,var(--safe-area-bottom));
|
||||
}
|
||||
.agent-room__composer textarea {
|
||||
font-size:16px;
|
||||
line-height:22px;
|
||||
}
|
||||
.agent-room__empty {
|
||||
max-width:300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.agent-room__message[data-animation="enter"] {
|
||||
animation:none;
|
||||
}
|
||||
.agent-room__typing-dots i {
|
||||
animation:none;
|
||||
opacity:.7;
|
||||
}
|
||||
.agent-room * {
|
||||
scroll-behavior:auto!important;
|
||||
transition:none!important;
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "./agent-conversation.css";
|
||||
|
||||
type Props = {
|
||||
room: AgentRoomHookResult;
|
||||
onOpenSidebar: () => void;
|
||||
/** False while a mobile navigation overlay covers this pane. */
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
type ReadingAnchor = { id: string; offset: number };
|
||||
const DRAFT_KEY = "sybil.pi-root.draft";
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
|
||||
function savedDraft() {
|
||||
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
|
||||
}
|
||||
function saveDraft(value: string) {
|
||||
try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ }
|
||||
}
|
||||
function metadata(message: Message): Record<string, unknown> {
|
||||
return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
}
|
||||
function dateOf(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date;
|
||||
}
|
||||
function dayKey(date: Date | null) {
|
||||
return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older";
|
||||
}
|
||||
function dayLabel(date: Date | null) {
|
||||
if (!date) return "Earlier";
|
||||
const today = new Date();
|
||||
if (dayKey(date) === dayKey(today)) return "Today";
|
||||
today.setDate(today.getDate() - 1);
|
||||
return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date);
|
||||
}
|
||||
function toolName(value: unknown) {
|
||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||
}
|
||||
|
||||
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
|
||||
const own = message.role === "user";
|
||||
const info = metadata(message);
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
const failed = status === "failed" || status === "unconfirmed";
|
||||
const date = dateOf(message.createdAt);
|
||||
return (
|
||||
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||
<div className="agent-room__message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
|
||||
</div>
|
||||
<div className="agent-room__message-meta">
|
||||
<span>{own ? "You" : "Sybil"}</span>
|
||||
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
|
||||
{own && status === "sending" ? <span className="agent-room__delivery" role="status">Sending…</span> : null}
|
||||
{own && status === "queued" ? <span className="agent-room__delivery" role="status"><Clock3 size={11} aria-hidden="true" /> Queued</span> : null}
|
||||
{own && failed ? <span className="agent-room__delivery is-error" title={typeof info.error === "string" ? info.error : undefined} role="status">
|
||||
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}
|
||||
<button type="button" onClick={() => onRetry(message.id)} aria-label="Retry message">Retry</button>
|
||||
</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||
const [draft, setDraft] = useState(savedDraft);
|
||||
const draftRef = useRef(draft);
|
||||
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
|
||||
const enteringMessageIdsRef = useRef(new Set<string>());
|
||||
const animatedEntryIdsRef = useRef(new Set<string>());
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
const restoredScrollTopRef = useRef<number | null>(null);
|
||||
const anchorRef = useRef<ReadingAnchor | null>(null);
|
||||
const markReadRef = useRef(room.markRead);
|
||||
const visibleRef = useRef(isVisible);
|
||||
const loadingRef = useRef(room.isLoading);
|
||||
const readFrameRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
markReadRef.current = room.markRead;
|
||||
visibleRef.current = isVisible;
|
||||
loadingRef.current = room.isLoading;
|
||||
|
||||
const distanceFromBottom = () => {
|
||||
const element = scrollerRef.current;
|
||||
return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity;
|
||||
};
|
||||
const scheduleRead = () => {
|
||||
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
||||
readFrameRef.current = requestAnimationFrame(() => {
|
||||
readFrameRef.current = null;
|
||||
if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) {
|
||||
markReadRef.current();
|
||||
}
|
||||
});
|
||||
};
|
||||
const rememberAnchor = () => {
|
||||
const element = scrollerRef.current;
|
||||
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
||||
const top = element.getBoundingClientRect().top;
|
||||
// Anchor to layout, independently of the bubble's entrance transform.
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
|
||||
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
|
||||
};
|
||||
const restoreViewport = () => {
|
||||
const element = scrollerRef.current;
|
||||
if (!element) return;
|
||||
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||
else if (anchorRef.current) {
|
||||
const anchor = anchorRef.current;
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
|
||||
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||
}
|
||||
restoredScrollTopRef.current = element.scrollTop;
|
||||
rememberAnchor();
|
||||
scheduleRead();
|
||||
};
|
||||
const jumpToLatest = () => {
|
||||
pinnedRef.current = true;
|
||||
anchorRef.current = null;
|
||||
setAtBottom(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
||||
}
|
||||
scheduleRead();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0;
|
||||
const typing = timeline.querySelector<HTMLElement>(".agent-room__typing");
|
||||
// Transfer the departing indicator's space to the reply before it grows.
|
||||
// Removing that space first would briefly move the transcript downward.
|
||||
let replacedTypingHeight = typing ? 0 : typingHeightRef.current;
|
||||
typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0;
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
for (const entry of timeline.querySelectorAll<HTMLElement>("[data-agent-entry-id]")) {
|
||||
const id = entry.dataset.agentEntryId!;
|
||||
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
|
||||
animatedEntryIdsRef.current.add(id);
|
||||
if (reduceMotion) continue;
|
||||
const height = entry.getBoundingClientRect().height;
|
||||
const spacing = entry.previousElementSibling ? gap : 0;
|
||||
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
|
||||
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
|
||||
if (isReply) replacedTypingHeight = 0;
|
||||
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
|
||||
// positive-Y transform from prematurely extending the scrollable area.
|
||||
entry.style.overflow = "clip";
|
||||
const animation = entry.animate([
|
||||
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
|
||||
{ height: `${height}px`, marginTop: "0px" },
|
||||
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
|
||||
entryAnimationsRef.current.add(animation);
|
||||
animation.onfinish = () => {
|
||||
entry.style.overflow = "";
|
||||
entryAnimationsRef.current.delete(animation);
|
||||
restoreViewport();
|
||||
};
|
||||
}
|
||||
}, [room.messages, room.isTyping]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreViewport();
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const observer = new ResizeObserver(restoreViewport);
|
||||
if (scrollerRef.current) observer.observe(scrollerRef.current);
|
||||
if (timelineRef.current) observer.observe(timelineRef.current);
|
||||
const onVisibility = () => scheduleRead();
|
||||
window.addEventListener("focus", onVisibility);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
observer.disconnect();
|
||||
for (const animation of entryAnimationsRef.current) animation.cancel();
|
||||
entryAnimationsRef.current.clear();
|
||||
window.removeEventListener("focus", onVisibility);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`;
|
||||
}, [draft]);
|
||||
|
||||
const updateDraft = (value: string) => {
|
||||
draftRef.current = value;
|
||||
saveDraft(value);
|
||||
setDraft(value);
|
||||
};
|
||||
|
||||
const retryMessage = (id: string) => {
|
||||
// Admission errors remain attached to their own bubble.
|
||||
void room.retryMessage(id).catch(() => {});
|
||||
};
|
||||
const submit = () => {
|
||||
const content = draftRef.current.trim();
|
||||
if (!content) return;
|
||||
const delivery = room.sendMessage(content);
|
||||
updateDraft("");
|
||||
jumpToLatest();
|
||||
void delivery.catch(() => {});
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const transcript = agentTranscriptMessages(room.messages);
|
||||
let previousDay = "";
|
||||
for (const message of transcript) {
|
||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||
enteringMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
seenMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
if (!room.isLoading) historyReadyRef.current = true;
|
||||
return transcript.map(message => {
|
||||
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
|
||||
previousDay = day;
|
||||
return (
|
||||
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
|
||||
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
|
||||
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [room.messages, room.isLoading, room.retryMessage]);
|
||||
|
||||
const activity = room.activity;
|
||||
const running = room.isTyping;
|
||||
const waiting = activity?.state === "waiting";
|
||||
const connected = room.connection === "connected";
|
||||
const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting";
|
||||
const activeTool = activity?.tools?.find(tool => tool.status !== "done");
|
||||
const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}`
|
||||
: activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : "";
|
||||
|
||||
return (
|
||||
<section className="agent-room" aria-label="Sybil Agent conversation">
|
||||
<header className="agent-room__header flex shrink-0 items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button type="button" size="icon" variant="ghost" className="workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick={onOpenSidebar} aria-label="Open sidebar">
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">Sybil Agent</h1>
|
||||
</div>
|
||||
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
|
||||
<span className="sr-only" role="status">{connectionLabel}</span>
|
||||
{!connected ? <Button type="button" size="icon" variant="ghost" onClick={room.retry} aria-label="Reconnect to Sybil Agent" title={connectionLabel}><RefreshCw className="h-4 w-4" /></Button> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="agent-room__body">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="agent-room__scroll"
|
||||
tabIndex={0}
|
||||
aria-label="Conversation messages"
|
||||
onScroll={() => {
|
||||
// A scroll event from our last resize correction can arrive after
|
||||
// the next animation frame has already grown a tall reply. That
|
||||
// temporary bottom distance does not mean the reader scrolled up.
|
||||
if (restoredScrollTopRef.current !== null && scrollerRef.current
|
||||
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
||||
const bottom = distanceFromBottom() <= 48;
|
||||
pinnedRef.current = bottom;
|
||||
setAtBottom(current => current === bottom ? current : bottom);
|
||||
rememberAnchor();
|
||||
if (bottom) scheduleRead();
|
||||
}}
|
||||
>
|
||||
<div ref={timelineRef} className="agent-room__timeline">
|
||||
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
||||
{messages}
|
||||
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
||||
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-room__composer-area">
|
||||
<div className="agent-room__composer-inner">
|
||||
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
|
||||
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
onInput={event => updateDraft(event.currentTarget.value)}
|
||||
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
|
||||
placeholder="Message Sybil Agent…"
|
||||
aria-label="Message Sybil Agent"
|
||||
enterkeyhint="send"
|
||||
/>
|
||||
<button type="submit" className="agent-room__send" disabled={!draft.trim()} aria-label="Send message"><SendHorizontal size={19} /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user