Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153aa5d2fa | ||
|
|
4389f805f4 | ||
|
|
e83281c400 | ||
|
|
ef3d79c428 | ||
|
|
dd6cd1ea7b | ||
|
|
3f9c1409fe | ||
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f | ||
|
|
26d917e96a | ||
|
|
922172fc60 | ||
|
|
5f9fc82b86 | ||
|
|
eb2b0d3ca0 | ||
|
|
42022bf055 | ||
|
|
1ef491f39a | ||
|
|
b540f01759 | ||
|
|
e719cbb92d | ||
|
|
cb5a973ac7 | ||
|
|
e2443162b0 | ||
|
|
048456b8a5 | ||
|
|
211fad972a | ||
|
|
a69c641481 | ||
|
|
9229896ad7 | ||
|
|
c5217b2710 | ||
|
|
abc1124d27 | ||
|
|
4721717022 | ||
|
|
87b7d9502f | ||
|
|
1952f4f358 | ||
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 | ||
|
|
a0e410155d | ||
|
|
b5a5d21767 | ||
|
|
0ad96f4f99 | ||
|
|
b0c2b74c16 | ||
|
|
14d2d8cb28 | ||
|
|
03836bbc4c | ||
|
|
76ce62025a | ||
|
|
b15473d24e | ||
|
|
a512a65844 | ||
|
|
207b44f67f | ||
|
|
c5ccd212c9 | ||
|
|
ee990dde5d | ||
|
|
036743fe41 | ||
|
|
4e7ea7ab68 |
@@ -1,187 +0,0 @@
|
|||||||
name: TestFlight Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "release/v*.*.*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
testflight:
|
|
||||||
runs-on: xcode
|
|
||||||
|
|
||||||
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: Import code signing certificates
|
|
||||||
uses: Apple-Actions/import-codesign-certs@v3
|
|
||||||
with:
|
|
||||||
p12-file-base64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
|
|
||||||
p12-password: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Create fastlane environment
|
|
||||||
working-directory: ios
|
|
||||||
env:
|
|
||||||
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
|
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${FASTLANE_USER:?FASTLANE_USER secret is required}"
|
|
||||||
: "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD:?FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD secret is required}"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf 'FASTLANE_USER=%s\n' "${FASTLANE_USER}"
|
|
||||||
printf 'FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=%s\n' "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}"
|
|
||||||
printf 'FASTLANE_SKIP_UPDATE_CHECK=1\n'
|
|
||||||
printf 'FASTLANE_HIDE_CHANGELOG=1\n'
|
|
||||||
} > .env
|
|
||||||
|
|
||||||
- name: Build and upload to TestFlight
|
|
||||||
working-directory: ios
|
|
||||||
env:
|
|
||||||
FASTLANE_DONT_STORE_PASSWORD: "1"
|
|
||||||
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}"
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
ios/fastlane/README.md
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
|||||||
Vendored
+32
@@ -1,3 +1,8 @@
|
|||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
"" close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -12,17 +17,44 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://server:8787/;
|
proxy_pass http://server:8787/;
|
||||||
proxy_http_version 1.1;
|
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 Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 {
|
location = /manifest.webmanifest {
|
||||||
default_type application/manifest+json;
|
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;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
expires -1;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ services:
|
|||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||||
|
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_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||||
|
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
|
||||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||||
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||||
|
|||||||
+141
-14
@@ -31,14 +31,16 @@ Chat upload limits:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||||
|
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, 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.
|
||||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ Chat upload limits:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
@@ -79,6 +81,8 @@ Behavior notes:
|
|||||||
|
|
||||||
### `GET /v1/workspace-items`
|
### `GET /v1/workspace-items`
|
||||||
- Response: `{ "items": WorkspaceItem[] }`
|
- 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:
|
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -87,6 +91,8 @@ Behavior notes:
|
|||||||
"type": "chat",
|
"type": "chat",
|
||||||
"id": "chat-id",
|
"id": "chat-id",
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
"starred": true,
|
"starred": true,
|
||||||
@@ -115,9 +121,19 @@ Behavior notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
- 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 legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
- The response currently combines up to 100 chats and up to 100 searches.
|
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
||||||
|
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
- `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
|
## Chats
|
||||||
|
|
||||||
### `GET /v1/chats`
|
### `GET /v1/chats`
|
||||||
@@ -128,7 +144,7 @@ Behavior notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"additionalSystemPrompt": "optional stored system prompt",
|
"additionalSystemPrompt": "optional stored system prompt",
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
@@ -146,15 +162,32 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `provider` and `model` must be supplied together when present.
|
- `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`.
|
- 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`.
|
- `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.
|
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
|
### `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`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||||
|
- 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`.
|
- `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.
|
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||||
@@ -181,13 +214,19 @@ Behavior notes:
|
|||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
||||||
|
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
||||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||||
|
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||||
|
|
||||||
### `DELETE /v1/chats/:chatId`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
- Not found: `404 { "message": "chat not found" }`
|
- 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`
|
### `GET /v1/chats/:chatId`
|
||||||
- Response: `{ "chat": ChatDetail }`
|
- Response: `{ "chat": ChatDetail }`
|
||||||
@@ -234,7 +273,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -285,6 +324,7 @@ Behavior notes:
|
|||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `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.
|
- `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 persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
@@ -293,13 +333,16 @@ Behavior notes:
|
|||||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
- 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 `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 `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||||
|
- For `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 `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||||
- Available 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.
|
- 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, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
|
||||||
|
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||||
@@ -317,6 +360,16 @@ Behavior notes:
|
|||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests 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.
|
- 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
|
## Searches
|
||||||
|
|
||||||
### `GET /v1/searches`
|
### `GET /v1/searches`
|
||||||
@@ -412,13 +465,15 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
@@ -464,13 +519,15 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
@@ -505,3 +562,73 @@ Behavior notes:
|
|||||||
```
|
```
|
||||||
|
|
||||||
For streaming contracts, see `docs/api/streaming-chat.md`.
|
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.
|
||||||
|
|||||||
+104
-7
@@ -1,27 +1,29 @@
|
|||||||
# Streaming Chat API Contract
|
# Streaming Chat API Contract
|
||||||
|
|
||||||
This document defines the server-sent events (SSE) contract for chat completions.
|
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
- `POST /v1/chats/:chatId/stream/attach`
|
- `POST /v1/chats/:chatId/stream/attach`
|
||||||
|
- `POST /v1/quick-questions/stream`
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||||
- Request body is JSON
|
- Request body is JSON
|
||||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||||
|
|
||||||
## Request Body
|
## Chat Completion Request Body
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"clientRequestId": "optional-client-generated-id",
|
||||||
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -61,15 +63,21 @@ Notes:
|
|||||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
|
- `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.
|
- 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.
|
- `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.
|
- `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`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
- 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`.
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||||
|
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
||||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
## Attach Endpoint
|
## Attach Endpoint
|
||||||
@@ -83,6 +91,32 @@ 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.
|
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 Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
@@ -173,18 +207,21 @@ Terminal tool-call event:
|
|||||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
- `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.
|
- `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.
|
- `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.
|
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||||
|
- `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.
|
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, 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.
|
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
|
||||||
|
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
- `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.
|
- `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.
|
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||||
|
|
||||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||||
- Stream may emit `tool_call` events while tool calls are executed.
|
- Stream may emit `tool_call` events while tool calls are executed.
|
||||||
- `delta` events 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.
|
- `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.
|
||||||
@@ -238,3 +275,63 @@ event: done
|
|||||||
data: {"type":"done","text":"Hello world"}
|
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.
|
||||||
|
|||||||
+5
-20
@@ -1,20 +1,5 @@
|
|||||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
ASC_KEY_ID=
|
||||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
ASC_ISSUER_ID=
|
||||||
FASTLANE_USER=you@example.com
|
ASC_KEY=
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
MATCH_PASSWORD=
|
||||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
MATCH_GIT_BASIC_AUTHORIZATION=
|
||||||
FASTLANE_HIDE_CHANGELOG=1
|
|
||||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
|
||||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
|
||||||
|
|
||||||
# Optional App Store Connect API key settings for non-interactive upload and
|
|
||||||
# TestFlight build-number lookup.
|
|
||||||
APP_STORE_CONNECT_API_KEY_ID=
|
|
||||||
APP_STORE_CONNECT_API_ISSUER_ID=
|
|
||||||
APP_STORE_CONNECT_API_KEY_PATH=
|
|
||||||
APP_STORE_CONNECT_API_KEY_CONTENT=
|
|
||||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
|
||||||
|
|
||||||
# Optional deployment overrides.
|
|
||||||
SYBIL_BUILD_NUMBER=
|
|
||||||
SYBIL_VERSION_TAG=
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
- 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.
|
- 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 Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
@@ -51,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
- xAI: `grok-3-mini`
|
- xAI: `grok-3-mini`
|
||||||
|
- Gemini: `gemini-3.5-flash`
|
||||||
- Hermes Agent: `hermes-agent`
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||||
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
<false/>
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -8,7 +8,7 @@ struct SybilApp: App
|
|||||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||||
SplitView()
|
SplitView()
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.start()
|
||||||
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
SybilMacQuickQuestionController.shared.stop()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
willConnectTo session: UISceneSession,
|
willConnectTo session: UISceneSession,
|
||||||
options connectionOptions: UIScene.ConnectionOptions
|
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 {
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func windowScene(
|
func windowScene(
|
||||||
_ windowScene: UIWindowScene,
|
_ windowScene: UIWindowScene,
|
||||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: Sybil
|
- package: Sybil
|
||||||
product: Sybil
|
product: Sybil
|
||||||
|
- target: SybilMacQuickQuestion
|
||||||
|
destinationFilters: [macCatalyst]
|
||||||
|
embed: true
|
||||||
|
link: false
|
||||||
|
codeSign: true
|
||||||
|
copy:
|
||||||
|
destination: plugins
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
@@ -24,14 +31,21 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: "1.10"
|
MARKETING_VERSION: "1.13.2"
|
||||||
CURRENT_PROJECT_VERSION: 11
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
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:
|
schemes:
|
||||||
Sybil:
|
Sybil:
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "fastlane", "~> 2.227"
|
gem "fastlane", "2.237.0"
|
||||||
|
|||||||
+75
-62
@@ -1,46 +1,49 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.9)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.9.0)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1109.0)
|
aws-partitions (1.1274.0)
|
||||||
aws-sdk-core (3.224.1)
|
aws-sdk-core (3.254.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.101.0)
|
aws-sdk-kms (1.130.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.254.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.188.0)
|
aws-sdk-s3 (1.228.1)
|
||||||
aws-sdk-core (~> 3, >= 3.224.1)
|
aws-sdk-core (~> 3, >= 3.254.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
|
benchmark (0.5.0)
|
||||||
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
csv (3.3.5)
|
csv (3.3.6)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.6.20240107)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.109.0)
|
excon (1.6.0)
|
||||||
|
logger
|
||||||
faraday (1.10.6)
|
faraday (1.10.6)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
@@ -70,42 +73,45 @@ GEM
|
|||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.1)
|
||||||
fastlane (2.230.0)
|
fastlane (2.237.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1.2)
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.9.0, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
base64 (~> 0.2.0)
|
base64 (~> 0.2)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
benchmark (>= 0.1.0)
|
||||||
|
bundler (>= 2.4.0, < 5.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
csv (~> 3.3)
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 2.0.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.10.3, < 4)
|
||||||
logger (>= 1.6, < 2.0)
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multi_json (~> 1.12)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
mutex_m (~> 0.3.0)
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2.0)
|
nkf (~> 0.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
@@ -120,41 +126,46 @@ GEM
|
|||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.106.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.28.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.18.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.65.0)
|
||||||
google-apis-storage_v1 (0.29.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-core (1.6.1)
|
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.2.2)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
base64 (~> 0.2)
|
||||||
google-cloud-errors (1.3.1)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-storage (1.45.0)
|
google-cloud-errors (1.7.0)
|
||||||
|
google-cloud-storage (1.62.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.29.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
google-logging-utils (0.2.0)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
googleauth (1.17.2)
|
||||||
jwt (>= 1.4, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
multi_json (~> 1.11)
|
google-cloud-env (~> 2.2)
|
||||||
|
google-logging-utils (~> 0.1)
|
||||||
|
jwt (>= 1.4, < 4.0)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
|
pstore (~> 0.1)
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.8)
|
||||||
@@ -162,22 +173,24 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.7.6)
|
json (2.21.1)
|
||||||
jwt (2.10.3)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.21.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (5.1.1)
|
pstore (0.2.1)
|
||||||
|
public_suffix (7.0.5)
|
||||||
rake (13.4.2)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
@@ -189,11 +202,10 @@ GEM
|
|||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.18.0)
|
signet (0.22.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 4.0)
|
||||||
multi_json (~> 1.10)
|
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
@@ -206,15 +218,16 @@ GEM
|
|||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unf (0.2.0)
|
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.28.1)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
atomos (~> 0.1.3)
|
atomos (~> 0.1.3)
|
||||||
|
base64
|
||||||
claide (>= 1.0.2, < 2.0)
|
claide (>= 1.0.2, < 2.0)
|
||||||
colored2 (~> 3.1)
|
colored2 (~> 3.1)
|
||||||
nanaimo (~> 0.4.0)
|
nanaimo (~> 0.4.0)
|
||||||
|
nkf
|
||||||
rexml (>= 3.3.6, < 4.0)
|
rexml (>= 3.3.6, < 4.0)
|
||||||
xcpretty (0.4.1)
|
xcpretty (0.4.1)
|
||||||
rouge (~> 3.28.0)
|
rouge (~> 3.28.0)
|
||||||
@@ -225,7 +238,7 @@ PLATFORMS
|
|||||||
ruby
|
ruby
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (~> 2.227)
|
fastlane (= 2.237.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.23
|
2.5.23
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ let package = Package(
|
|||||||
name: "Sybil",
|
name: "Sybil",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||||
]),
|
],
|
||||||
|
resources: [.process("Resources")]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SybilTests",
|
name: "SybilTests",
|
||||||
dependencies: ["Sybil"]
|
dependencies: ["Sybil"]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "character-still.png",
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
SybilQuickQuestionView(
|
SybilQuickQuestionView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
|||||||
)
|
)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.task {
|
.task {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
presentPendingQuickQuestionIfPossible()
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
@@ -101,12 +106,18 @@ public struct SplitView: View {
|
|||||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||||
presentPendingQuickQuestionIfPossible()
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
|
.onChange(of: isQuickQuestionPresented) { _, presented in
|
||||||
|
viewModel.agent.isModalPresented = presented
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
viewModel.markAppActiveForNetwork()
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
@@ -151,8 +162,13 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPendingQuickQuestionPresentation = false
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
SybilMacQuickQuestionController.shared.show()
|
||||||
|
#else
|
||||||
quickQuestionFocusRequest += 1
|
quickQuestionFocusRequest += 1
|
||||||
isQuickQuestionPresented = true
|
isQuickQuestionPresented = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleQuickQuestionDismissed() {
|
private func handleQuickQuestionDismissed() {
|
||||||
@@ -161,23 +177,57 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
public static let workspaceWindowID = "sybil-workspace"
|
||||||
|
|
||||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some Commands {
|
public var body: some Commands {
|
||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button("New Chat") {
|
Button("New Chat") {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
windowCommands.newChatOrWindow(
|
||||||
|
newChat: keyboardActions?.newChat,
|
||||||
|
openWindow: openNewWindow
|
||||||
|
)
|
||||||
|
#else
|
||||||
keyboardActions?.newChat()
|
keyboardActions?.newChat()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||||
|
#else
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Button("New Window", action: openNewWindow)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
|
|
||||||
Button("New Search") {
|
Button("New Search") {
|
||||||
keyboardActions?.newSearch()
|
keyboardActions?.newSearch()
|
||||||
}
|
}
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||||
|
#else
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Divider()
|
||||||
|
Button("Quick Question") {
|
||||||
|
SybilMacQuickQuestionController.shared.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.space, modifiers: .option)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Conversation") {
|
CommandMenu("Conversation") {
|
||||||
@@ -194,6 +244,13 @@ public struct SybilCommands: Commands {
|
|||||||
.disabled(keyboardActions == nil)
|
.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 {
|
private struct SybilKeyboardActions {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor SybilAPIClient: SybilAPIClienting {
|
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||||
private let configuration: APIConfiguration
|
private let configuration: APIConfiguration
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
@@ -40,6 +40,48 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
self.session = session
|
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 {
|
func verifySession() async throws -> AuthSession {
|
||||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||||
}
|
}
|
||||||
@@ -49,6 +91,15 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.items
|
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] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||||
return response.chats
|
return response.chats
|
||||||
@@ -74,6 +125,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
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 {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats/\(chatID)",
|
"/v1/chats/\(chatID)",
|
||||||
@@ -183,6 +244,29 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
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(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -257,9 +341,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
body: AnyEncodable? = nil,
|
body: AnyEncodable? = nil,
|
||||||
|
queryItems: [URLQueryItem] = [],
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
|
||||||
|
|
||||||
SybilLog.debug(
|
SybilLog.debug(
|
||||||
SybilLog.network,
|
SybilLog.network,
|
||||||
@@ -311,12 +396,14 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
path: String,
|
path: String,
|
||||||
method: String,
|
method: String,
|
||||||
body: AnyEncodable?,
|
body: AnyEncodable?,
|
||||||
acceptsSSE: Bool
|
acceptsSSE: Bool,
|
||||||
|
queryItems: [URLQueryItem] = []
|
||||||
) throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
let url = try buildURL(path: path)
|
let url = try buildURL(path: path, queryItems: queryItems)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = method
|
request.httpMethod = method
|
||||||
request.timeoutInterval = 120
|
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
|
||||||
|
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
|
||||||
|
|
||||||
if acceptsSSE {
|
if acceptsSSE {
|
||||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
@@ -336,7 +423,7 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(path: String) throws -> URL {
|
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
|
||||||
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||||
throw APIError.invalidBaseURL
|
throw APIError.invalidBaseURL
|
||||||
}
|
}
|
||||||
@@ -347,6 +434,9 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
basePath.removeLast()
|
basePath.removeLast()
|
||||||
}
|
}
|
||||||
components.path = "\(basePath)/\(trimmedPath)"
|
components.path = "\(basePath)/\(trimmedPath)"
|
||||||
|
if !queryItems.isEmpty {
|
||||||
|
components.queryItems = (components.queryItems ?? []) + queryItems
|
||||||
|
}
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
throw APIError.invalidBaseURL
|
throw APIError.invalidBaseURL
|
||||||
@@ -664,6 +754,12 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
var userLocation: String? = nil
|
var userLocation: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||||
|
var provider: Provider
|
||||||
|
var model: String
|
||||||
|
var question: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var provider: Provider?
|
var provider: Provider?
|
||||||
@@ -675,6 +771,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
|||||||
var title: String
|
var title: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatForkBody: Encodable {
|
||||||
|
var messageId: String?
|
||||||
|
}
|
||||||
|
|
||||||
private struct StarUpdateBody: Encodable {
|
private struct StarUpdateBody: Encodable {
|
||||||
var starred: Bool
|
var starred: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||||
|
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
|
||||||
func listChats() async throws -> [ChatSummary]
|
func listChats() async throws -> [ChatSummary]
|
||||||
func createChat(
|
func createChat(
|
||||||
title: String?,
|
title: String?,
|
||||||
@@ -11,6 +12,7 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
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 updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
@@ -27,6 +29,10 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
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 allMessages = room.messages
|
||||||
|
let startIndex = historyStartID.flatMap { id in allMessages.firstIndex(where: { $0.id == id }) } ?? max(0, allMessages.count - historyLimit)
|
||||||
|
let displayed = Array(allMessages.dropFirst(startIndex))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
if message.isToolCallLog {
|
||||||
|
AgentToolCard(message: message)
|
||||||
|
} else {
|
||||||
|
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, tool
|
||||||
|
// expansion 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AgentToolCard: View {
|
||||||
|
var message: Message
|
||||||
|
@State private var expanded = false
|
||||||
|
var body: some View {
|
||||||
|
if let metadata = message.toolCallMetadata {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button {
|
||||||
|
expanded.toggle()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ToolCallActivityChip(metadata: metadata, fallbackContent: message.content, createdAt: message.createdAt, compactLayout: true)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("agent-tool-\(metadata.toolName ?? "tool")")
|
||||||
|
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
|
||||||
|
if expanded {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if let args = metadata.args { detail("Input", text: pretty(args)) }
|
||||||
|
if let error = metadata.error { detail("Error", text: error) }
|
||||||
|
if let result = metadata.result { detail("Result", text: pretty(result)) }
|
||||||
|
else if let preview = metadata.resultPreview { detail("Result", text: preview) }
|
||||||
|
if metadata.args == nil && metadata.result == nil && metadata.resultPreview == nil && metadata.error == nil {
|
||||||
|
Text(message.content).font(.sybil(.caption))
|
||||||
|
}
|
||||||
|
}.padding(.top, 8).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(8)
|
||||||
|
.background(SybilTheme.surface.opacity(0.8), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func detail(_ title: String, text: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-tool-\(title.lowercased())")
|
||||||
|
Text(text).font(.system(size: 12, design: .monospaced)).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func pretty(_ value: JSONValue) -> String {
|
||||||
|
if let text = value.stringValue { return text }
|
||||||
|
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,13 +1,17 @@
|
|||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilChatTranscriptView: View {
|
struct SybilChatTranscriptView: View {
|
||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
var isForking: Bool = false
|
||||||
|
var onForkResponse: ((String) -> Void)? = nil
|
||||||
|
|
||||||
@State private var hasTrackedToolCallMessages = false
|
@State private var hasTrackedToolCallMessages = false
|
||||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
@@ -41,7 +45,15 @@ struct SybilChatTranscriptView: View {
|
|||||||
ForEach(renderItems) { item in
|
ForEach(renderItems) { item in
|
||||||
switch item {
|
switch item {
|
||||||
case let .message(message):
|
case let .message(message):
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(
|
||||||
|
message: message,
|
||||||
|
isSending: isSending,
|
||||||
|
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||||
|
isForking: isForking,
|
||||||
|
onFork: {
|
||||||
|
onForkResponse?(message.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
ToolCallStackView(
|
ToolCallStackView(
|
||||||
@@ -58,7 +70,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
.frame(height: 18 + bottomContentInset)
|
.frame(height: 18 + bottomContentInset)
|
||||||
.id(bottomAnchorID)
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
@@ -66,7 +79,6 @@ struct SybilChatTranscriptView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
scrollToBottom(with: proxy, animated: false)
|
|
||||||
}
|
}
|
||||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
@@ -137,9 +149,13 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MessageBubble: View {
|
struct MessageBubble: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
var isForking: Bool
|
||||||
|
var onFork: (() -> Void)?
|
||||||
|
var agentStyle = false
|
||||||
|
|
||||||
private var toolCallMetadata: ToolCallMetadata? {
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
message.toolCallMetadata
|
message.toolCallMetadata
|
||||||
@@ -149,13 +165,46 @@ private struct MessageBubble: View {
|
|||||||
message.role == .user
|
message.role == .user
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var isStreamingAssistant: Bool {
|
||||||
message.id.hasPrefix("temp-assistant-") &&
|
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||||
isSending &&
|
}
|
||||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
leadingSpacer
|
leadingSpacer
|
||||||
|
|
||||||
@@ -174,25 +223,26 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isPendingAssistant {
|
if isStreamingAssistant {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking…")
|
)
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
if isUser {
|
||||||
.tint(SybilTheme.primary)
|
Text(message.content)
|
||||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.font(.sybil(.body))
|
||||||
.markdownTheme(.sybilReadable)
|
} else {
|
||||||
|
Markdown(message.content)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||||
|
.markdownTheme(.sybilReadable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
@@ -203,6 +253,10 @@ private struct MessageBubble: View {
|
|||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
.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 {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 0)
|
RoundedRectangle(cornerRadius: 0)
|
||||||
.fill(Color.clear)
|
.fill(Color.clear)
|
||||||
@@ -232,6 +286,123 @@ private 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 ToolCallStackView: View {
|
||||||
private struct CardLayout {
|
private struct CardLayout {
|
||||||
var x: CGFloat
|
var x: CGFloat
|
||||||
@@ -440,7 +611,7 @@ private struct ToolCallStackCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ToolCallActivityChip: View {
|
struct ToolCallActivityChip: View {
|
||||||
enum VisualState {
|
enum VisualState {
|
||||||
case initiated
|
case initiated
|
||||||
case completed
|
case completed
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#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,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case openai
|
||||||
case anthropic
|
case anthropic
|
||||||
case xai
|
case xai
|
||||||
|
case gemini
|
||||||
case hermesAgent = "hermes-agent"
|
case hermesAgent = "hermes-agent"
|
||||||
|
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case .openai: return "OpenAI"
|
case .openai: return "OpenAI"
|
||||||
case .anthropic: return "Anthropic"
|
case .anthropic: return "Anthropic"
|
||||||
case .xai: return "xAI"
|
case .xai: return "xAI"
|
||||||
|
case .gemini: return "Gemini"
|
||||||
case .hermesAgent: return "Hermes Agent"
|
case .hermesAgent: return "Hermes Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +154,8 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
|||||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
public var parentChatId: String? = nil
|
||||||
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -162,6 +166,39 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var lastUsedModel: String?
|
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 struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -182,6 +219,9 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var query: 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 createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -196,6 +236,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = chat.id
|
self.id = chat.id
|
||||||
self.title = chat.title
|
self.title = chat.title
|
||||||
self.query = nil
|
self.query = nil
|
||||||
|
self.parentChatId = chat.parentChatId
|
||||||
|
self.titleGenerationPending = chat.titleGenerationPending
|
||||||
self.createdAt = chat.createdAt
|
self.createdAt = chat.createdAt
|
||||||
self.updatedAt = chat.updatedAt
|
self.updatedAt = chat.updatedAt
|
||||||
self.starred = chat.starred
|
self.starred = chat.starred
|
||||||
@@ -211,6 +253,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = search.id
|
self.id = search.id
|
||||||
self.title = search.title
|
self.title = search.title
|
||||||
self.query = search.query
|
self.query = search.query
|
||||||
|
self.parentChatId = nil
|
||||||
|
self.titleGenerationPending = false
|
||||||
self.createdAt = search.createdAt
|
self.createdAt = search.createdAt
|
||||||
self.updatedAt = search.updatedAt
|
self.updatedAt = search.updatedAt
|
||||||
self.starred = search.starred
|
self.starred = search.starred
|
||||||
@@ -226,6 +270,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
return ChatSummary(
|
return ChatSummary(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
|
parentChatId: parentChatId,
|
||||||
|
titleGenerationPending: titleGenerationPending,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
starred: starred,
|
starred: starred,
|
||||||
@@ -251,6 +297,45 @@ 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 struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -274,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
toolName: object["toolName"]?.stringValue ?? name,
|
toolName: object["toolName"]?.stringValue ?? name,
|
||||||
status: object["status"]?.stringValue,
|
status: object["status"]?.stringValue,
|
||||||
summary: object["summary"]?.stringValue,
|
summary: object["summary"]?.stringValue,
|
||||||
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
durationMs: object["durationMs"]?.numberValue.flatMap { value in
|
||||||
|
value >= 0 && value < Double(Int.max) ? Int(value) : nil
|
||||||
|
},
|
||||||
error: object["error"]?.stringValue,
|
error: object["error"]?.stringValue,
|
||||||
resultPreview: object["resultPreview"]?.stringValue
|
resultPreview: object["resultPreview"]?.stringValue,
|
||||||
|
args: object["args"],
|
||||||
|
result: object["result"],
|
||||||
|
startedAt: object["startedAt"]?.stringValue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
|||||||
public var durationMs: Int?
|
public var durationMs: Int?
|
||||||
public var error: String?
|
public var error: String?
|
||||||
public var resultPreview: 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 {
|
public enum JSONValue: Codable, Hashable, Sendable {
|
||||||
@@ -389,6 +482,8 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
public var parentChatId: String? = nil
|
||||||
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -400,6 +495,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var messages: [Message]
|
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 struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -611,6 +741,11 @@ struct SearchListResponse: Codable {
|
|||||||
var searches: [SearchSummary]
|
var searches: [SearchSummary]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceSearchResponse: Codable, Sendable {
|
||||||
|
var items: [WorkspaceItem]
|
||||||
|
var hasMore: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct WorkspaceListResponse: Codable {
|
struct WorkspaceListResponse: Codable {
|
||||||
var items: [WorkspaceItem]
|
var items: [WorkspaceItem]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum PhoneRoute: Hashable {
|
enum PhoneRoute: Hashable {
|
||||||
|
case agent
|
||||||
case chat(String)
|
case chat(String)
|
||||||
case search(String)
|
case search(String)
|
||||||
case draftChat
|
case draftChat
|
||||||
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
|
|||||||
return .chat(chatID)
|
return .chat(chatID)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
return .search(searchID)
|
return .search(searchID)
|
||||||
|
case .agent: return .agent
|
||||||
case .settings:
|
case .settings:
|
||||||
return .settings
|
return .settings
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ struct SybilPhoneShellView: View {
|
|||||||
return .chat(chatID)
|
return .chat(chatID)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
return .search(searchID)
|
return .search(searchID)
|
||||||
|
case .agent: return .agent
|
||||||
case .draftChat, .draftSearch, .settings:
|
case .draftChat, .draftSearch, .settings:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,10 @@ struct SybilPhoneShellView: View {
|
|||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.animation(.easeOut(duration: 0.22), value: route)
|
.animation(.easeOut(duration: 0.22), value: route)
|
||||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
.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
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
@@ -130,7 +137,10 @@ struct SybilPhoneShellView: View {
|
|||||||
route: route,
|
route: route,
|
||||||
onRequestBack: { _ in showSidebarOverlay() },
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
onShowSidebar: showSidebarOverlay
|
onShowSidebar: showSidebarOverlay,
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
showRoute(.chat(chatID))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
@@ -239,6 +249,7 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||||
|
dismissSidebarKeyboard()
|
||||||
let update = {
|
let update = {
|
||||||
route = nextRoute
|
route = nextRoute
|
||||||
}
|
}
|
||||||
@@ -268,12 +279,19 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func hideSidebarOverlay() {
|
private func hideSidebarOverlay() {
|
||||||
|
dismissSidebarKeyboard()
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
isSidebarOverlayPresented = false
|
isSidebarOverlayPresented = false
|
||||||
}
|
}
|
||||||
resetSidebarSwipe(animated: 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) {
|
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||||
return
|
return
|
||||||
@@ -511,7 +529,12 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
},
|
},
|
||||||
onSelect: { item in
|
onSelect: { item in
|
||||||
onSelect(item.selection)
|
onSelect(item.selection)
|
||||||
}
|
},
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
onSelect(.chat(chatID))
|
||||||
|
},
|
||||||
|
onSelectAgent: { onSelect(.agent) },
|
||||||
|
isAgentSelected: highlightedSelection == .agent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -585,6 +608,7 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
let onRequestNewChat: (() -> Void)?
|
let onRequestNewChat: (() -> Void)?
|
||||||
let onShowSidebar: () -> Void
|
let onShowSidebar: () -> Void
|
||||||
|
let onOpenForkedChat: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
@@ -593,7 +617,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
navigationLeadingControl: .showSidebar,
|
navigationLeadingControl: .showSidebar,
|
||||||
onShowSidebar: onShowSidebar,
|
onShowSidebar: onShowSidebar,
|
||||||
onRequestBack: onRequestBack,
|
onRequestBack: onRequestBack,
|
||||||
onRequestNewChat: onRequestNewChat
|
onRequestNewChat: onRequestNewChat,
|
||||||
|
onOpenForkedChat: onOpenForkedChat
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
@@ -613,6 +638,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
|
case .agent:
|
||||||
|
viewModel.select(.agent)
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
case .draftSearch:
|
case .draftSearch:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@FocusState private var promptFocused: Bool
|
@FocusState private var promptFocused: Bool
|
||||||
|
@State private var promptSelection: TextSelection?
|
||||||
|
|
||||||
private var hasAnswerContent: Bool {
|
private var hasAnswerContent: Bool {
|
||||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
promptFocused = true
|
promptFocused = true
|
||||||
|
let prompt = viewModel.quickQuestionPrompt
|
||||||
|
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +62,11 @@ struct SybilQuickQuestionView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if hasAnswerContent {
|
if hasAnswerContent {
|
||||||
ForEach(viewModel.quickQuestionMessages) { message in
|
ForEach(viewModel.quickQuestionMessages) { message in
|
||||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
QuickQuestionMessageView(
|
||||||
|
message: message,
|
||||||
|
isSending: viewModel.isQuickQuestionSending,
|
||||||
|
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = viewModel.quickQuestionError {
|
if let error = viewModel.quickQuestionError {
|
||||||
@@ -88,46 +95,19 @@ struct SybilQuickQuestionView: View {
|
|||||||
private var composer: some View {
|
private var composer: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .bottom, spacing: 10) {
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
TextField(
|
SybilPromptField(
|
||||||
"Ask anything...",
|
placeholder: "Ask anything...",
|
||||||
text: Binding(
|
text: Binding(
|
||||||
get: { viewModel.quickQuestionPrompt },
|
get: { viewModel.quickQuestionPrompt },
|
||||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
),
|
),
|
||||||
axis: .vertical
|
focused: $promptFocused,
|
||||||
|
selection: $promptSelection,
|
||||||
|
axis: .horizontal,
|
||||||
|
submit: submitQuestion
|
||||||
)
|
)
|
||||||
.focused($promptFocused)
|
|
||||||
.font(.body)
|
|
||||||
.textInputAutocapitalization(.sentences)
|
|
||||||
.autocorrectionDisabled(false)
|
|
||||||
.lineLimit(1 ... 6)
|
|
||||||
.submitLabel(.send)
|
|
||||||
.onSubmit(submitQuestion)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(SybilTheme.composerGradient)
|
|
||||||
.opacity(0.98)
|
|
||||||
)
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
|
|
||||||
Button(action: submitQuestion) {
|
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: 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")
|
.accessibilityLabel("Ask quick question")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,11 +239,11 @@ private struct QuickQuestionPickerPill: View {
|
|||||||
private struct QuickQuestionMessageView: View {
|
private struct QuickQuestionMessageView: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||||
isSending &&
|
return streamingAssistantPresentation
|
||||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -272,15 +252,12 @@ private struct QuickQuestionMessageView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} else if isPendingAssistant {
|
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking...")
|
)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,7 +98,8 @@ struct SybilSearchResultsView: View {
|
|||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 20 + topContentInset)
|
.padding(.top, 20 + topContentInset)
|
||||||
.padding(.bottom, 20 + bottomContentInset)
|
.padding(.bottom, 20 + bottomContentInset)
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
|
|||||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
|
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||||
|
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +46,18 @@ final class SybilSettingsStore {
|
|||||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||||
|
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
|
||||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
self.preferredModelByProvider = preferredModels
|
self.preferredModelByProvider = preferredModels
|
||||||
|
|
||||||
self.quickQuestionPreferredProvider =
|
self.quickQuestionPreferredProvider =
|
||||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.quickQuestionPreferredModelByProvider = [
|
self.quickQuestionPreferredModelByProvider = [
|
||||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
|
||||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
.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"
|
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -72,12 +76,14 @@ final class SybilSettingsStore {
|
|||||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
|
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||||
|
|
||||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@@ -111,8 +112,12 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
|
var onSelectAgent: (() -> Void)? = nil
|
||||||
|
var isAgentSelected: Bool? = nil
|
||||||
@State private var renameTarget: SidebarItem?
|
@State private var renameTarget: SidebarItem?
|
||||||
@State private var renameTitle = ""
|
@State private var renameTitle = ""
|
||||||
|
@FocusState private var isSearchFocused: Bool
|
||||||
|
|
||||||
private var isRenameAlertPresented: Binding<Bool> {
|
private var isRenameAlertPresented: Binding<Bool> {
|
||||||
Binding {
|
Binding {
|
||||||
@@ -126,37 +131,122 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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 {
|
Group {
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
if viewModel.isSearchingSidebar ||
|
||||||
|
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
Text("Loading conversations…")
|
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
} 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 {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "message.badge")
|
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
Text("Start a chat or run your first search.")
|
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
ForEach(viewModel.visibleSidebarItems) { item in
|
||||||
Button {
|
Button {
|
||||||
|
isSearchFocused = false
|
||||||
onSelect(item)
|
onSelect(item)
|
||||||
} label: {
|
} label: {
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
@@ -168,13 +258,26 @@ struct SybilSidebarItemList: View {
|
|||||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.kind == .chat {
|
if case let .chat(chatID) = item.selection {
|
||||||
|
Button {
|
||||||
|
forkChat(item)
|
||||||
|
} label: {
|
||||||
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
renameTarget = item
|
renameTarget = item
|
||||||
renameTitle = item.title
|
renameTitle = item.title
|
||||||
} label: {
|
} label: {
|
||||||
Label("Rename", systemImage: "pencil")
|
Label("Rename", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = chatID
|
||||||
|
} label: {
|
||||||
|
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -186,9 +289,16 @@ 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)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
}
|
}
|
||||||
@@ -215,11 +325,52 @@ struct SybilSidebarItemList: View {
|
|||||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.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 {
|
struct SybilSidebarRow: View {
|
||||||
var item: SidebarItem
|
var item: SidebarItem
|
||||||
var isSelected: Bool
|
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 {
|
private var isHighlighted: Bool {
|
||||||
isSelected
|
isSelected
|
||||||
@@ -281,6 +432,15 @@ struct SybilSidebarRow: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.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)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ extension Font {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SybilLayout {
|
||||||
|
static let webContentMaxWidth: CGFloat = 896
|
||||||
|
}
|
||||||
|
|
||||||
enum SybilTheme {
|
enum SybilTheme {
|
||||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
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)
|
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum DraftKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SidebarSelection: Hashable {
|
enum SidebarSelection: Hashable {
|
||||||
|
case agent
|
||||||
case chat(String)
|
case chat(String)
|
||||||
case search(String)
|
case search(String)
|
||||||
case settings
|
case settings
|
||||||
@@ -17,6 +18,8 @@ enum SidebarSelection: Hashable {
|
|||||||
return "chat:\(chatID)"
|
return "chat:\(chatID)"
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
return "search:\(searchID)"
|
return "search:\(searchID)"
|
||||||
|
case .agent:
|
||||||
|
return "agent"
|
||||||
case .settings:
|
case .settings:
|
||||||
return "settings"
|
return "settings"
|
||||||
}
|
}
|
||||||
@@ -38,11 +41,75 @@ struct SidebarItem: Identifiable, Hashable {
|
|||||||
var starredAt: Date?
|
var starredAt: Date?
|
||||||
var initiatedLabel: String?
|
var initiatedLabel: String?
|
||||||
var isRunning: Bool
|
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 {
|
private struct PendingChatState {
|
||||||
var chatID: String?
|
var chatID: String?
|
||||||
var messages: [Message]
|
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 {
|
private enum ActiveSendContext: Hashable {
|
||||||
@@ -89,6 +156,7 @@ private actor SearchStreamStatus {
|
|||||||
@Observable
|
@Observable
|
||||||
final class SybilViewModel {
|
final class SybilViewModel {
|
||||||
let settings: SybilSettingsStore
|
let settings: SybilSettingsStore
|
||||||
|
let agent = SybilAgentRoom()
|
||||||
|
|
||||||
var isCheckingSession = true
|
var isCheckingSession = true
|
||||||
var isAuthenticated = false
|
var isAuthenticated = false
|
||||||
@@ -98,6 +166,15 @@ final class SybilViewModel {
|
|||||||
var chats: [ChatSummary] = []
|
var chats: [ChatSummary] = []
|
||||||
var searches: [SearchSummary] = []
|
var searches: [SearchSummary] = []
|
||||||
var workspaceItems: [WorkspaceItem] = []
|
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 selectedItem: SidebarSelection?
|
||||||
var selectedChat: ChatDetail?
|
var selectedChat: ChatDetail?
|
||||||
@@ -107,6 +184,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var isForkingChat = false
|
||||||
var chatBottomPinRequestID = 0
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -125,6 +203,7 @@ final class SybilViewModel {
|
|||||||
var quickQuestionSubmittedModel: String?
|
var quickQuestionSubmittedModel: String?
|
||||||
var isQuickQuestionSending = false
|
var isQuickQuestionSending = false
|
||||||
var isConvertingQuickQuestion = false
|
var isConvertingQuickQuestion = false
|
||||||
|
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
@@ -157,9 +236,10 @@ final class SybilViewModel {
|
|||||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"],
|
.xai: ["grok-3-mini"],
|
||||||
|
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
.hermesAgent: ["hermes-agent"]
|
.hermesAgent: ["hermes-agent"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -263,6 +343,8 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
return "Search"
|
return "Search"
|
||||||
|
|
||||||
|
case .agent:
|
||||||
|
return "Sybil Agent"
|
||||||
case .settings:
|
case .settings:
|
||||||
return "Settings"
|
return "Settings"
|
||||||
}
|
}
|
||||||
@@ -302,6 +384,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var showsComposer: Bool {
|
var showsComposer: Bool {
|
||||||
|
if selectedItem == .agent { return false }
|
||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -359,6 +442,30 @@ final class SybilViewModel {
|
|||||||
return canonical
|
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? {
|
var displayedSearch: SearchDetail? {
|
||||||
if case let .search(searchID) = selectedItem,
|
if case let .search(searchID) = selectedItem,
|
||||||
let activeSearch = activeSearchDetails[searchID] {
|
let activeSearch = activeSearchDetails[searchID] {
|
||||||
@@ -392,7 +499,19 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
workspaceItems.map { item in
|
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
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case .chat:
|
case .chat:
|
||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
@@ -405,16 +524,21 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
initiatedLabel = nil
|
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(
|
return SidebarItem(
|
||||||
selection: .chat(item.id),
|
selection: .chat(item.id),
|
||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: item.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
starred: item.starred,
|
starred: starOwner.starred,
|
||||||
starredAt: item.starredAt,
|
starredAt: starOwner.starredAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id),
|
||||||
|
searchSnippet: item.searchSnippet
|
||||||
)
|
)
|
||||||
|
|
||||||
case .search:
|
case .search:
|
||||||
@@ -426,20 +550,70 @@ final class SybilViewModel {
|
|||||||
starred: item.starred,
|
starred: item.starred,
|
||||||
starredAt: item.starredAt,
|
starredAt: item.starredAt,
|
||||||
initiatedLabel: "exa",
|
initiatedLabel: "exa",
|
||||||
isRunning: isSearchRowRunning(item.id)
|
isRunning: isSearchRowRunning(item.id),
|
||||||
|
searchSnippet: item.searchSnippet
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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? {
|
var selectedChatSummary: ChatSummary? {
|
||||||
guard case let .chat(chatID) = selectedItem else { return nil }
|
guard case let .chat(chatID) = selectedItem else { return nil }
|
||||||
return chats.first(where: { $0.id == chatID })
|
return chats.first(where: { $0.id == chatID }) ??
|
||||||
|
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedSearchSummary: SearchSummary? {
|
var selectedSearchSummary: SearchSummary? {
|
||||||
guard case let .search(searchID) = selectedItem else { return nil }
|
guard case let .search(searchID) = selectedItem else { return nil }
|
||||||
return searches.first(where: { $0.id == searchID })
|
return searches.first(where: { $0.id == searchID }) ??
|
||||||
|
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasRefreshableSelection: Bool {
|
var hasRefreshableSelection: Bool {
|
||||||
@@ -450,7 +624,7 @@ final class SybilViewModel {
|
|||||||
switch selectedItem {
|
switch selectedItem {
|
||||||
case .chat, .search:
|
case .chat, .search:
|
||||||
return true
|
return true
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,6 +640,8 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reconnect() async {
|
func reconnect() async {
|
||||||
|
agent.disconnect()
|
||||||
|
setSidebarSearchQuery("")
|
||||||
isCheckingSession = true
|
isCheckingSession = true
|
||||||
authError = nil
|
authError = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -503,6 +679,10 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await loadInitialData(using: client)
|
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()
|
startActiveRunPolling()
|
||||||
} catch {
|
} catch {
|
||||||
stopActiveRunPolling()
|
stopActiveRunPolling()
|
||||||
@@ -616,6 +796,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionTask = nil
|
quickQuestionTask = nil
|
||||||
quickQuestionRunID = nil
|
quickQuestionRunID = nil
|
||||||
isQuickQuestionSending = false
|
isQuickQuestionSending = false
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -632,9 +813,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelQuickQuestion()
|
cancelQuickQuestion()
|
||||||
|
// Lock submission before the task starts, including repeated Return
|
||||||
|
// events delivered in the same main-actor turn.
|
||||||
|
isQuickQuestionSending = true
|
||||||
let selectedProvider = quickQuestionProvider
|
let selectedProvider = quickQuestionProvider
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
guard let self else {
|
guard let self, !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||||
@@ -686,6 +870,8 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
|
parentChatId: chat.parentChatId,
|
||||||
|
titleGenerationPending: chat.titleGenerationPending,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -766,6 +952,15 @@ final class SybilViewModel {
|
|||||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
||||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
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 {
|
if draftKind == nil, selectedItem == selection {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
if case .search = selection {
|
if case .search = selection {
|
||||||
@@ -798,7 +993,7 @@ final class SybilViewModel {
|
|||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
|
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
return nil
|
return nil
|
||||||
@@ -816,7 +1011,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectAdjacentSidebarItem(offset: Int) {
|
private func selectAdjacentSidebarItem(offset: Int) {
|
||||||
let items = sidebarItems
|
let items = visibleSidebarItems
|
||||||
guard !items.isEmpty else {
|
guard !items.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -836,6 +1031,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteItem(_ selection: SidebarSelection) async {
|
func deleteItem(_ selection: SidebarSelection) async {
|
||||||
|
guard selection != .agent else { return }
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -848,10 +1044,11 @@ final class SybilViewModel {
|
|||||||
try await client.deleteChat(chatID: chatID)
|
try await client.deleteChat(chatID: chatID)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
try await client.deleteSearch(searchID: searchID)
|
try await client.deleteSearch(searchID: searchID)
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
await refreshCollections(preferredSelection: nil)
|
await refreshCollections(preferredSelection: nil)
|
||||||
|
await refreshSidebarSearch()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
||||||
@@ -860,6 +1057,36 @@ 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 {
|
func renameChat(chatID: String, title: String) async {
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
@@ -877,6 +1104,7 @@ final class SybilViewModel {
|
|||||||
do {
|
do {
|
||||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||||
applyChatSummary(updated, moveToFront: true)
|
applyChatSummary(updated, moveToFront: true)
|
||||||
|
await refreshSidebarSearch()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||||
@@ -884,6 +1112,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||||
|
guard selection != .agent else { return }
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -895,12 +1124,13 @@ final class SybilViewModel {
|
|||||||
let client = try client()
|
let client = try client()
|
||||||
switch selection {
|
switch selection {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
let rootChatID = chatFamilyRootID(for: chatID)
|
||||||
|
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
||||||
applyChatSummary(updated, moveToFront: false)
|
applyChatSummary(updated, moveToFront: false)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||||
applySearchSummary(updated, moveToFront: false)
|
applySearchSummary(updated, moveToFront: false)
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -923,12 +1153,14 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isAppActive = false
|
isAppActive = false
|
||||||
|
agent.setActive(false)
|
||||||
appLifecycleGeneration += 1
|
appLifecycleGeneration += 1
|
||||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func markAppActiveForNetwork() {
|
func markAppActiveForNetwork() {
|
||||||
isAppActive = true
|
isAppActive = true
|
||||||
|
agent.setActive(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||||
@@ -975,6 +1207,10 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshSidebarCollectionsFromPullToRefresh() async {
|
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||||
|
if isSidebarSearchActive {
|
||||||
|
await refreshSidebarSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard isAuthenticated, !isCheckingSession else {
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1130,6 +1366,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionSubmittedPrompt = prompt
|
quickQuestionSubmittedPrompt = prompt
|
||||||
quickQuestionSubmittedProvider = provider
|
quickQuestionSubmittedProvider = provider
|
||||||
quickQuestionSubmittedModel = model
|
quickQuestionSubmittedModel = model
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
quickQuestionMessages = [
|
quickQuestionMessages = [
|
||||||
Message(
|
Message(
|
||||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||||
@@ -1152,13 +1389,11 @@ final class SybilViewModel {
|
|||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await client().runCompletionStream(
|
try await client().runQuickQuestionStream(
|
||||||
body: CompletionStreamRequest(
|
body: QuickQuestionStreamRequest(
|
||||||
chatId: nil,
|
|
||||||
persist: false,
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model: model,
|
model: model,
|
||||||
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
question: prompt
|
||||||
)
|
)
|
||||||
) { [weak self] event in
|
) { [weak self] event in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -1176,6 +1411,8 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
quickQuestionError = normalizeAPIError(error)
|
quickQuestionError = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||||
}
|
}
|
||||||
@@ -1190,17 +1427,16 @@ final class SybilViewModel {
|
|||||||
upsertQuickQuestionToolCallMessage(payload)
|
upsertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||||
mutateQuickQuestionAssistantMessage { existing in
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutateQuickQuestionAssistantMessage { _ in
|
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||||
payload.text
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
case .ignored:
|
||||||
@@ -1252,7 +1488,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if let nextSelection {
|
if let nextSelection {
|
||||||
switch nextSelection {
|
switch nextSelection {
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
case .chat, .search:
|
case .chat, .search:
|
||||||
@@ -1315,7 +1551,15 @@ final class SybilViewModel {
|
|||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
applyWorkspaceItems(nextWorkspaceItems)
|
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)
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
@@ -1453,6 +1697,8 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedChat?.id == chat.id {
|
if selectedChat?.id == chat.id {
|
||||||
selectedChat?.title = chat.title
|
selectedChat?.title = chat.title
|
||||||
|
selectedChat?.parentChatId = chat.parentChatId
|
||||||
|
selectedChat?.titleGenerationPending = chat.titleGenerationPending
|
||||||
selectedChat?.updatedAt = chat.updatedAt
|
selectedChat?.updatedAt = chat.updatedAt
|
||||||
selectedChat?.starred = chat.starred
|
selectedChat?.starred = chat.starred
|
||||||
selectedChat?.starredAt = chat.starredAt
|
selectedChat?.starredAt = chat.starredAt
|
||||||
@@ -1491,6 +1737,11 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
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 }) {
|
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||||
workspaceItems.remove(at: existingIndex)
|
workspaceItems.remove(at: existingIndex)
|
||||||
if moveToFront {
|
if moveToFront {
|
||||||
@@ -1504,6 +1755,22 @@ final class SybilViewModel {
|
|||||||
workspaceItems.insert(item, at: 0)
|
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() {
|
private func attachToVisibleActiveRunIfNeeded() {
|
||||||
guard draftKind == nil else {
|
guard draftKind == nil else {
|
||||||
return
|
return
|
||||||
@@ -1532,7 +1799,7 @@ final class SybilViewModel {
|
|||||||
await self?.attachToActiveSearchStream(searchID: searchID)
|
await self?.attachToActiveSearchStream(searchID: searchID)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .settings, nil:
|
case .settings, .agent, nil:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1584,21 +1851,28 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
pendingChatStates[chatID] = nil
|
|
||||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||||
selectedSearch = nil
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
|
selectedSearch = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
pendingChatStates[chatID] = nil
|
||||||
} catch {
|
} catch {
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
pendingChatStates[chatID] = nil
|
defer {
|
||||||
|
pendingChatStates[chatID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
|
|
||||||
if isActiveStreamNotFound(error) {
|
if isActiveStreamNotFound(error) {
|
||||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
@@ -1612,8 +1886,11 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
do {
|
do {
|
||||||
selectedChat = try await client().getChat(chatID: chatID)
|
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||||
selectedSearch = nil
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
|
selectedSearch = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||||
}
|
}
|
||||||
@@ -1731,12 +2008,13 @@ final class SybilViewModel {
|
|||||||
return selectedChat?.id != chatID
|
return selectedChat?.id != chatID
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
return selectedSearch?.id != searchID
|
return selectedSearch?.id != searchID
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshSelectionIfNeeded() async {
|
private func refreshSelectionIfNeeded() async {
|
||||||
|
if selectedItem == .agent { isLoadingSelection = false; return }
|
||||||
guard let target = selectedItem else {
|
guard let target = selectedItem else {
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
@@ -1751,13 +2029,16 @@ final class SybilViewModel {
|
|||||||
switch target {
|
switch target {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||||
|
let isSelectingDifferentChat = selectedChat?.id != chatID
|
||||||
let chat = try await client.getChat(chatID: chatID)
|
let chat = try await client.getChat(chatID: chatID)
|
||||||
guard selectedItem == target, draftKind == nil else {
|
guard selectedItem == target, draftKind == nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
requestChatBottomPin()
|
if isSelectingDifferentChat {
|
||||||
|
requestChatBottomPin()
|
||||||
|
}
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
@@ -1775,7 +2056,7 @@ final class SybilViewModel {
|
|||||||
selectedSearch = search
|
selectedSearch = search
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
|
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -1830,6 +2111,13 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
defer {
|
||||||
|
if let resolvedChatID = chatID {
|
||||||
|
pendingChatStates[resolvedChatID] = nil
|
||||||
|
} else {
|
||||||
|
clearPendingChatState(for: sendContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
requestChatBottomPin()
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
@@ -1850,6 +2138,8 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
|
parentChatId: created.parentChatId,
|
||||||
|
titleGenerationPending: created.titleGenerationPending,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
starred: created.starred,
|
starred: created.starred,
|
||||||
@@ -1908,7 +2198,7 @@ final class SybilViewModel {
|
|||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
let streamStartedWhileInactive = !isAppActive
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
if shouldRequestChatTitle(baseChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -2017,17 +2307,13 @@ final class SybilViewModel {
|
|||||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||||
payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
case .ignored:
|
||||||
@@ -2205,29 +2491,68 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
|
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
|
||||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||||
guard let index else {
|
applyStreamingAssistantContentUpdate(
|
||||||
return
|
update,
|
||||||
}
|
to: &pending.messages,
|
||||||
|
assistantMessagePrefix: "temp-assistant-"
|
||||||
var message = pending.messages[index]
|
)
|
||||||
message.content = transform(message.content)
|
|
||||||
pending.messages[index] = message
|
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
guard let index else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
let update = pending.streamingAssistantState.receiveDone(text)
|
||||||
|
applyStreamingAssistantContentUpdate(
|
||||||
|
update,
|
||||||
|
to: &pending.messages,
|
||||||
|
assistantMessagePrefix: "temp-assistant-"
|
||||||
|
)
|
||||||
|
pendingChatStates[chatID] = pending
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPendingAssistantError(chatID: String) {
|
||||||
|
guard var pending = pendingChatStates[chatID] 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
|
||||||
}
|
}
|
||||||
|
|
||||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
@@ -2235,6 +2560,16 @@ final class SybilViewModel {
|
|||||||
return
|
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)" }) {
|
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)
|
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
@@ -2253,6 +2588,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
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)" }) {
|
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)
|
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||||
return
|
return
|
||||||
@@ -2402,7 +2743,8 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||||
switch context {
|
switch context {
|
||||||
case .draftChat:
|
case let .draftChat(identity):
|
||||||
|
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
pendingChatStates[chatID] = nil
|
pendingChatStates[chatID] = nil
|
||||||
@@ -2430,7 +2772,7 @@ final class SybilViewModel {
|
|||||||
return chats.contains(where: { $0.id == chatID })
|
return chats.contains(where: { $0.id == chatID })
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
return searches.contains(where: { $0.id == searchID })
|
return searches.contains(where: { $0.id == searchID })
|
||||||
case .settings:
|
case .settings, .agent:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2619,20 +2961,10 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
||||||
if let detail, detail.id == chatID {
|
if chat.titleGenerationPending {
|
||||||
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
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,4 +1,3 @@
|
|||||||
import ImageIO
|
|
||||||
import Observation
|
import Observation
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -19,6 +18,7 @@ struct SybilWorkspaceView: View {
|
|||||||
var onShowSidebar: (() -> Void)? = nil
|
var onShowSidebar: (() -> Void)? = nil
|
||||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@@ -155,8 +155,6 @@ struct SybilWorkspaceView: View {
|
|||||||
workspaceContentStack
|
workspaceContentStack
|
||||||
|
|
||||||
if showsCustomWorkspaceNavigation {
|
if showsCustomWorkspaceNavigation {
|
||||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
customWorkspaceNavigationBar
|
customWorkspaceNavigationBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +173,8 @@ struct SybilWorkspaceView: View {
|
|||||||
if isSettingsSelected {
|
if isSettingsSelected {
|
||||||
SybilSettingsView(viewModel: viewModel)
|
SybilSettingsView(viewModel: viewModel)
|
||||||
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||||
|
} else if viewModel.selectedItem == .agent {
|
||||||
|
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||||
} else if viewModel.isSearchMode {
|
} else if viewModel.isSearchMode {
|
||||||
SybilSearchResultsView(
|
SybilSearchResultsView(
|
||||||
search: viewModel.displayedSearch,
|
search: viewModel.displayedSearch,
|
||||||
@@ -193,9 +193,12 @@ struct SybilWorkspaceView: View {
|
|||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
|
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||||
|
isForking: viewModel.isForkingChat,
|
||||||
|
onForkResponse: forkResponse
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
@@ -229,6 +232,28 @@ 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 {
|
private var customWorkspaceNavigationBar: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
@@ -262,7 +287,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.78)
|
.minimumScaleFactor(0.78)
|
||||||
|
|
||||||
Text(selectedProviderModelSubtitle)
|
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
|
||||||
.font(.sybil(size: 10, weight: .medium))
|
.font(.sybil(size: 10, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -484,7 +509,7 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var workspaceNavigationTrailingControl: some View {
|
private var workspaceNavigationTrailingControl: some View {
|
||||||
if isSettingsSelected {
|
if isSettingsSelected || viewModel.selectedItem == .agent {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else if viewModel.isSearchMode {
|
} else if viewModel.isSearchMode {
|
||||||
searchModeNavigationLabel
|
searchModeNavigationLabel
|
||||||
@@ -588,46 +613,19 @@ struct SybilWorkspaceView: View {
|
|||||||
.accessibilityLabel("Attach file")
|
.accessibilityLabel("Attach file")
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField(
|
SybilPromptField(
|
||||||
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||||
text: $viewModel.composer,
|
text: $viewModel.composer,
|
||||||
axis: .vertical
|
focused: $composerFocused,
|
||||||
|
submit: submitComposer
|
||||||
)
|
)
|
||||||
.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)
|
|
||||||
|
|
||||||
Button {
|
SybilPromptSendButton(
|
||||||
submitComposer()
|
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
|
||||||
} label: {
|
canSend: viewModel.canSendComposer,
|
||||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
submit: submitComposer
|
||||||
.font(.system(size: 17, weight: .semibold))
|
)
|
||||||
.frame(width: 40, height: 40)
|
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
|
||||||
.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)
|
.padding(.horizontal, 14)
|
||||||
@@ -1348,173 +1346,6 @@ 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 {
|
private struct NewChatSwipeBackdrop: View {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var hasLatched: Bool
|
var hasLatched: Bool
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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 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
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# 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. Tool cards expand to show input, result,
|
||||||
|
and error details. Attachments and model selection 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.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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()
|
||||||
|
let failures = app.buttons.matching(identifier: "agent-tool-fixture_error")
|
||||||
|
failures.element(boundBy: failures.count - 1).tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3))
|
||||||
|
XCTAssertTrue(app.staticTexts["agent-tool-error"].exists)
|
||||||
|
capture("agent-tool-error")
|
||||||
|
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 testExpandedToolCollapseToShortHistory() async throws {
|
||||||
|
try await control(["action": "long-tool"])
|
||||||
|
let tool = app.buttons["agent-tool-fixture_long"]
|
||||||
|
XCTAssertTrue(tool.waitForExistence(timeout: 5))
|
||||||
|
tool.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3))
|
||||||
|
let scroll = app.scrollViews.firstMatch
|
||||||
|
for _ in 0..<12 {
|
||||||
|
if tool.isHittable { break }
|
||||||
|
scroll.swipeDown()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(tool.isHittable)
|
||||||
|
tool.tap()
|
||||||
|
XCTAssertEqual(tool.value as? String, "Collapsed")
|
||||||
|
try await control(["action": "reply", "content": "Reply after collapsing a tall tool"])
|
||||||
|
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
|
capture("agent-collapsed-tool-short-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,9 +1,2 @@
|
|||||||
require "dotenv"
|
app_identifier("net.buzzert.sybil2")
|
||||||
|
team_id("DQQH5H6GBD")
|
||||||
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?
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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 with
|
|
||||||
`Apple-Actions/import-codesign-certs`, builds and uploads the app with fastlane,
|
|
||||||
then creates or updates the matching Gitea release with the generated IPA as an
|
|
||||||
asset.
|
|
||||||
|
|
||||||
Required repository secrets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
APPSTORE_CERTIFICATES_FILE_BASE64
|
|
||||||
APPSTORE_CERTIFICATES_PASSWORD
|
|
||||||
FASTLANE_USER
|
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
+34
-164
@@ -1,177 +1,47 @@
|
|||||||
require "dotenv"
|
|
||||||
require "open3"
|
|
||||||
require "shellwords"
|
|
||||||
require "yaml"
|
|
||||||
|
|
||||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
|
||||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
|
||||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
|
||||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
|
||||||
IOS_ROOT = File.expand_path("..", __dir__)
|
|
||||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
|
||||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
|
||||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
|
||||||
SCHEME = "Sybil"
|
|
||||||
TARGET = "SybilApp"
|
|
||||||
|
|
||||||
def present?(value)
|
|
||||||
!value.to_s.strip.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
def capture(command)
|
|
||||||
stdout, stderr, status = Open3.capture3(command)
|
|
||||||
return stdout.strip if status.success?
|
|
||||||
|
|
||||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def app_project_settings
|
|
||||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_marketing_version
|
|
||||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_build_number
|
|
||||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_version_tag(tag)
|
|
||||||
version = tag.to_s.strip.sub(%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 app_store_connect_key_options
|
|
||||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
|
||||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
|
||||||
return nil unless present?(key_id) && present?(issuer_id)
|
|
||||||
|
|
||||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
|
||||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
|
||||||
if present?(key_path)
|
|
||||||
{
|
|
||||||
key_id: key_id,
|
|
||||||
issuer_id: issuer_id,
|
|
||||||
key_filepath: key_path
|
|
||||||
}
|
|
||||||
elsif present?(key_content)
|
|
||||||
{
|
|
||||||
key_id: key_id,
|
|
||||||
issuer_id: issuer_id,
|
|
||||||
key_content: key_content,
|
|
||||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
desc "Build a release tag and upload it to TestFlight"
|
||||||
lane :version do
|
|
||||||
UI.message("Git tag version: #{release_version}")
|
|
||||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
|
||||||
UI.message("Checked-in build number: #{local_build_number}")
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "Build Sybil and upload it to TestFlight"
|
|
||||||
lane :beta do
|
lane :beta do
|
||||||
version = release_version
|
setup_ci
|
||||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
|
||||||
api_key = nil
|
|
||||||
|
|
||||||
if app_store_connect_key_options
|
match(type: "appstore")
|
||||||
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
|
||||||
|
tag = ENV.fetch("GITHUB_REF_NAME")
|
||||||
|
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
||||||
|
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
|
||||||
|
|
||||||
|
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
|
||||||
|
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
ios_dir = File.expand_path("..", __dir__)
|
||||||
|
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
|
||||||
|
contents = File.read(project_spec)
|
||||||
|
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
|
||||||
|
UI.user_error!("Could not find version settings in #{project_spec}")
|
||||||
end
|
end
|
||||||
|
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||||
|
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
|
||||||
|
File.write(project_spec, contents)
|
||||||
|
|
||||||
unless present?(build_number)
|
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
|
||||||
build_number = (local_build_number + 1).to_s
|
|
||||||
|
|
||||||
if api_key
|
api_key = app_store_connect_api_key(
|
||||||
begin
|
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||||
latest = latest_testflight_build_number(
|
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||||
app_identifier: APP_IDENTIFIER,
|
key_content: ENV.fetch("ASC_KEY"),
|
||||||
version: version,
|
is_key_content_base64: true
|
||||||
api_key: api_key,
|
|
||||||
initial_build_number: local_build_number
|
|
||||||
).to_i
|
|
||||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
|
||||||
rescue StandardError => e
|
|
||||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
|
||||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
|
||||||
|
|
||||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
|
||||||
|
|
||||||
xcode_args = [
|
|
||||||
"-allowProvisioningUpdates",
|
|
||||||
xcode_build_setting("MARKETING_VERSION", version),
|
|
||||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
|
||||||
].join(" ")
|
|
||||||
|
|
||||||
ipa_path = build_app(
|
|
||||||
project: PROJECT_FILE,
|
|
||||||
scheme: SCHEME,
|
|
||||||
clean: true,
|
|
||||||
sdk: "iphoneos",
|
|
||||||
export_method: "app-store",
|
|
||||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
|
||||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
|
||||||
xcargs: xcode_args,
|
|
||||||
export_xcargs: "-allowProvisioningUpdates",
|
|
||||||
export_options: {
|
|
||||||
method: "app-store-connect",
|
|
||||||
destination: "export",
|
|
||||||
signingStyle: "automatic",
|
|
||||||
teamID: TEAM_ID,
|
|
||||||
manageAppVersionAndBuildNumber: false,
|
|
||||||
uploadSymbols: true,
|
|
||||||
stripSwiftSymbols: true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
build_app(
|
||||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
||||||
|
scheme: "Sybil"
|
||||||
|
)
|
||||||
|
|
||||||
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
upload_to_testflight(
|
||||||
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
api_key: api_key,
|
||||||
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
skip_waiting_for_build_processing: true,
|
||||||
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
uses_non_exempt_encryption: false
|
||||||
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
)
|
||||||
|
|
||||||
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
|
||||||
sh([
|
|
||||||
"xcrun altool",
|
|
||||||
"--upload-package #{ipa_path.shellescape}",
|
|
||||||
"--platform ios",
|
|
||||||
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
|
||||||
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
|
||||||
"--bundle-version #{build_number.shellescape}",
|
|
||||||
"--bundle-short-version-string #{version.shellescape}",
|
|
||||||
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
|
||||||
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
|
||||||
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
|
||||||
"--show-progress"
|
|
||||||
].join(" "))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
|
||||||
|
storage_mode("git")
|
||||||
|
type("appstore")
|
||||||
|
|
||||||
|
app_identifier(["net.buzzert.sybil2"])
|
||||||
|
team_id("DQQH5H6GBD")
|
||||||
|
profile_name("Sybil AppStore CI")
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
fastlane documentation
|
|
||||||
----
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
Make sure you have the latest version of the Xcode command line tools installed:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
|
||||||
|
|
||||||
# Available Actions
|
|
||||||
|
|
||||||
## iOS
|
|
||||||
|
|
||||||
### ios version
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios version
|
|
||||||
```
|
|
||||||
|
|
||||||
Show the version Fastlane will stamp into the next TestFlight archive
|
|
||||||
|
|
||||||
### ios beta
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios beta
|
|
||||||
```
|
|
||||||
|
|
||||||
Build Sybil and upload it to TestFlight
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
|
||||||
|
|
||||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
|
||||||
|
|
||||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
||||||
+7
-1
@@ -9,7 +9,7 @@ generate:
|
|||||||
xcodegen --spec project.yml
|
xcodegen --spec project.yml
|
||||||
|
|
||||||
build: generate
|
build: generate
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||||
@@ -24,6 +24,12 @@ run: generate
|
|||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
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:
|
beta:
|
||||||
fastlane ios beta
|
fastlane ios beta
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,31 @@ packages:
|
|||||||
path: Packages/Sybil
|
path: Packages/Sybil
|
||||||
include:
|
include:
|
||||||
- Apps/Sybil/project.yml
|
- 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
|
||||||
|
|||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/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"
|
||||||
Executable
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#!/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"
|
||||||
+30
-4
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
||||||
- Personal chat database (chats/messages + LLM call log)
|
- Personal chat database (chats/messages + LLM call log)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `OPENAI_API_KEY`
|
- `OPENAI_API_KEY`
|
||||||
- `ANTHROPIC_API_KEY`
|
- `ANTHROPIC_API_KEY`
|
||||||
- `XAI_API_KEY`
|
- `XAI_API_KEY`
|
||||||
|
- `GEMINI_API_KEY`
|
||||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
- `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_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`)
|
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||||
- `EXA_API_KEY`
|
- `EXA_API_KEY`
|
||||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
|
||||||
|
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
|
||||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
- `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_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
||||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)
|
||||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
- `CHAT_CODEX_REMOTE_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_USER` (optional SSH user when host does not include one)
|
||||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||||
@@ -58,7 +60,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -104,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
|||||||
- User accounts + per-device API keys
|
- User accounts + per-device API keys
|
||||||
- Postgres support + migrations for prod
|
- Postgres support + migrations for prod
|
||||||
- Attachments + embeddings + semantic search
|
- 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,10 +22,12 @@
|
|||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prisma": "^6.6.0",
|
"prisma": "^6.6.0",
|
||||||
|
"ws": "8.22.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
|
"@types/ws": "8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
@@ -876,6 +878,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"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": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -2241,6 +2253,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
|||||||
@@ -31,10 +31,12 @@
|
|||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prisma": "^6.6.0",
|
"prisma": "^6.6.0",
|
||||||
|
"ws": "8.22.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
|
"@types/ws": "8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add durable root grouping and one-time fork title generation state.
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- 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");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE "AgentReadState" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"cursor" TEXT NOT NULL DEFAULT '0',
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
|
gemini
|
||||||
hermes_agent @map("hermes-agent")
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ model Chat {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
title String?
|
title String?
|
||||||
|
titleGenerationPending Boolean @default(false)
|
||||||
|
|
||||||
initiatedProvider Provider?
|
initiatedProvider Provider?
|
||||||
initiatedModel String?
|
initiatedModel String?
|
||||||
@@ -63,11 +65,18 @@ model Chat {
|
|||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
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[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
projectItems ProjectItem[]
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([parentChatId])
|
||||||
|
@@index([updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
@@ -206,3 +215,11 @@ model ProjectItem {
|
|||||||
@@index([chatId])
|
@@index([chatId])
|
||||||
@@index([searchId])
|
@@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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
+13
-1
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||||
},
|
},
|
||||||
z.enum(["exa", "searxng"]).default("exa")
|
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||||
);
|
);
|
||||||
|
|
||||||
const BooleanFlagSchema = z.preprocess((value) => {
|
const BooleanFlagSchema = z.preprocess((value) => {
|
||||||
@@ -66,10 +66,14 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
XAI_API_KEY: z.string().optional(),
|
XAI_API_KEY: z.string().optional(),
|
||||||
|
GEMINI_API_KEY: z.string().optional(),
|
||||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||||
HERMES_AGENT_MODEL: 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(),
|
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-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||||
@@ -99,6 +103,14 @@ const EnvSchema = z.object({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["BRAVE_SEARCH_API_KEY"],
|
||||||
|
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
+3
-1
@@ -6,6 +6,7 @@ import sensible from "@fastify/sensible";
|
|||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { ensureDatabaseReady } from "./db-init.js";
|
import { ensureDatabaseReady } from "./db-init.js";
|
||||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||||
|
import { registerAgentRoutes } from "./agent.js";
|
||||||
import { registerRoutes } from "./routes.js";
|
import { registerRoutes } from "./routes.js";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -26,7 +27,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
|||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: true,
|
origin: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
|
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(swagger, {
|
await app.register(swagger, {
|
||||||
@@ -86,6 +87,7 @@ app.addHook("onClose", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await registerRoutes(app);
|
await registerRoutes(app);
|
||||||
|
await registerAgentRoutes(app);
|
||||||
|
|
||||||
await app.listen({ port: env.PORT, host: env.HOST });
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { convert as htmlToText } from "html-to-text";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { searchBrave } from "../search/brave.js";
|
||||||
import { exaClient } from "../search/exa.js";
|
import { exaClient } from "../search/exa.js";
|
||||||
import { searchSearxng } from "../search/searxng.js";
|
import { searchSearxng } from "../search/searxng.js";
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
@@ -507,11 +508,33 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
|
const response = await searchBrave(args.query, {
|
||||||
|
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
||||||
|
includeDomains: args.includeDomains,
|
||||||
|
excludeDomains: args.excludeDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
searchEngine: "brave",
|
||||||
|
query: args.query,
|
||||||
|
requestId: response.requestId,
|
||||||
|
results: response.results.map((result, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
...result,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
const args = WebSearchArgsSchema.parse(input);
|
const args = WebSearchArgsSchema.parse(input);
|
||||||
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
||||||
return runSearxngWebSearchTool(args);
|
return runSearxngWebSearchTool(args);
|
||||||
}
|
}
|
||||||
|
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
|
||||||
|
return runBraveWebSearchTool(args);
|
||||||
|
}
|
||||||
return runExaWebSearchTool(args);
|
return runExaWebSearchTool(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import {
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
getUnstreamedText,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolAwareUsage,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
buildTopLevelSystemPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
parseImageDataUrl,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
type GeminiClient = {
|
||||||
|
apiKey: string;
|
||||||
|
baseURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERNAL_CORRECTION =
|
||||||
|
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||||
|
|
||||||
|
function normalizeModelResourceName(model: string) {
|
||||||
|
const trimmed = model.trim().replace(/^\/+/, "");
|
||||||
|
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
|
||||||
|
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
|
||||||
|
url.searchParams.set("key", client.apiKey);
|
||||||
|
for (const [key, value] of Object.entries(extraParams)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
if (params.temperature !== undefined) config.temperature = params.temperature;
|
||||||
|
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
|
||||||
|
return Object.keys(config).length ? config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
||||||
|
const input = schema as Record<string, unknown>;
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (typeof input.type === "string") output.type = input.type;
|
||||||
|
if (typeof input.description === "string") output.description = input.description;
|
||||||
|
if (typeof input.format === "string") output.format = input.format;
|
||||||
|
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
|
||||||
|
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
|
||||||
|
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
|
||||||
|
|
||||||
|
const items = toGeminiJsonSchema(input.items);
|
||||||
|
if (items) output.items = items;
|
||||||
|
|
||||||
|
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(input.properties)) {
|
||||||
|
const propertySchema = toGeminiJsonSchema(value);
|
||||||
|
if (propertySchema) properties[key] = propertySchema;
|
||||||
|
}
|
||||||
|
if (Object.keys(properties).length) output.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(output).length ? output : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeminiTools(tools: any[]) {
|
||||||
|
const functionDeclarations = tools
|
||||||
|
.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return null;
|
||||||
|
const declaration: Record<string, unknown> = {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
};
|
||||||
|
const parameters = toGeminiJsonSchema(tool.function.parameters);
|
||||||
|
if (parameters) declaration.parameters = parameters;
|
||||||
|
return declaration;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
const source = parseImageDataUrl(attachment);
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType: source.mediaType,
|
||||||
|
data: source.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length ? parts : [{ text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationContent(message: ChatMessage) {
|
||||||
|
if (message.role === "system") {
|
||||||
|
throw new Error("System messages must be handled separately for Gemini.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role === "assistant" ? "model" : "user",
|
||||||
|
parts: toContentParts(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseContents(messages: ChatMessage[]) {
|
||||||
|
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
|
||||||
|
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
|
||||||
|
return text ? { parts: [{ text }] } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
const normalized = normalizeUsage(usage);
|
||||||
|
if (!normalized) return false;
|
||||||
|
acc.inputTokens += normalized.inputTokens;
|
||||||
|
acc.outputTokens += normalized.outputTokens;
|
||||||
|
acc.totalTokens += normalized.totalTokens;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUsage(usage: any) {
|
||||||
|
if (!usage) return null;
|
||||||
|
const inputTokens = usage.promptTokenCount ?? 0;
|
||||||
|
const outputTokens = usage.candidatesTokenCount ?? 0;
|
||||||
|
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
|
||||||
|
return { inputTokens, outputTokens, totalTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidate(response: any) {
|
||||||
|
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParts(response: any) {
|
||||||
|
const parts = getCandidate(response)?.content?.parts;
|
||||||
|
return Array.isArray(parts) ? parts : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any) {
|
||||||
|
return getParts(response)
|
||||||
|
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyToolArgs(args: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(args ?? {});
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return parts
|
||||||
|
.filter((part) => part?.functionCall)
|
||||||
|
.map((part, index) => ({
|
||||||
|
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: part.functionCall.name ?? "unknown_tool",
|
||||||
|
arguments: stringifyToolArgs(part.functionCall.args),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
|
||||||
|
return {
|
||||||
|
functionResponse: {
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
response: toolResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCorrection(conversation: any[], text: string) {
|
||||||
|
conversation.push({ role: "model", parts: [{ text }] });
|
||||||
|
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseGeminiResponse(response: Response) {
|
||||||
|
const bodyText = await response.text();
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = bodyText ? JSON.parse(bodyText) : null;
|
||||||
|
} catch {
|
||||||
|
body = { raw: bodyText };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||||
|
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parseGeminiResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureMessage(response: any, text: string, toolCallCount: number) {
|
||||||
|
const promptBlockReason = response?.promptFeedback?.blockReason;
|
||||||
|
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
|
||||||
|
|
||||||
|
const candidate = getCandidate(response);
|
||||||
|
const finishReason = candidate?.finishReason;
|
||||||
|
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
|
||||||
|
if (text || toolCallCount > 0) return null;
|
||||||
|
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
|
||||||
|
const tools = toGeminiTools(enabledTools);
|
||||||
|
return {
|
||||||
|
contents: conversation,
|
||||||
|
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
|
||||||
|
generationConfig: generationConfig(params),
|
||||||
|
tools,
|
||||||
|
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const response = await generateContent(params, buildRequest(params, conversation, enabledTools));
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
|
||||||
|
|
||||||
|
const parts = getParts(response);
|
||||||
|
const text = extractText(response);
|
||||||
|
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
|
||||||
|
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({ role: "model", parts });
|
||||||
|
|
||||||
|
const toolResultParts: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({ role: "user", parts: toolResultParts });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSseBoundary(buffer: string) {
|
||||||
|
const crlf = buffer.indexOf("\r\n\r\n");
|
||||||
|
const lf = buffer.indexOf("\n\n");
|
||||||
|
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
||||||
|
if (lf === -1) return { index: crlf, length: 4 };
|
||||||
|
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvent(rawEvent: string) {
|
||||||
|
const data = rawEvent
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice("data:".length).trimStart())
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (!data || data === "[DONE]") return null;
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||||
|
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await parseGeminiResponse(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Gemini stream response did not include a body.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let boundary = findSseBoundary(buffer);
|
||||||
|
while (boundary) {
|
||||||
|
const rawEvent = buffer.slice(0, boundary.index);
|
||||||
|
buffer = buffer.slice(boundary.index + boundary.length);
|
||||||
|
const event = parseSseEvent(rawEvent);
|
||||||
|
if (event) yield event;
|
||||||
|
boundary = findSseBoundary(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode();
|
||||||
|
const tail = buffer.trim();
|
||||||
|
if (tail) {
|
||||||
|
const event = parseSseEvent(tail);
|
||||||
|
if (event) yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
let text = "";
|
||||||
|
let latestUsage: any = null;
|
||||||
|
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
|
||||||
|
rawResponses.push(response);
|
||||||
|
if (response?.usageMetadata) latestUsage = response.usageMetadata;
|
||||||
|
const failureMessage = getFailureMessage(response, extractText(response), 0);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
const delta = extractText(response);
|
||||||
|
if (delta) {
|
||||||
|
text += delta;
|
||||||
|
yield { type: "delta", text: delta };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const roundParts: any[] = [];
|
||||||
|
let roundText = "";
|
||||||
|
let latestRoundResponse: any = null;
|
||||||
|
let latestRoundUsage: any = null;
|
||||||
|
|
||||||
|
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
|
||||||
|
rawResponses.push(response);
|
||||||
|
latestRoundResponse = response;
|
||||||
|
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
|
||||||
|
roundParts.push(...getParts(response));
|
||||||
|
roundText += extractText(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
|
||||||
|
|
||||||
|
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
|
||||||
|
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(roundText, "");
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({ role: "model", parts: roundParts });
|
||||||
|
|
||||||
|
const toolResultParts: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({ role: "user", parts: toolResultParts });
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: {
|
||||||
|
streamed: true,
|
||||||
|
responses: rawResponses,
|
||||||
|
toolCallsUsed: totalToolCalls,
|
||||||
|
toolCallLimitReached: true,
|
||||||
|
api: "gemini.streamGenerateContent",
|
||||||
|
},
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,45 @@ import type { ChatMessage } from "../types.js";
|
|||||||
const INTERNAL_CORRECTION =
|
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.";
|
"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[]) {
|
function toTools(tools: any[]) {
|
||||||
return tools
|
return tools
|
||||||
.map((tool) => {
|
.map((tool) => {
|
||||||
@@ -160,11 +199,12 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
|||||||
|
|
||||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
if (!enabledTools.length) {
|
if (!enabledTools.length) {
|
||||||
const response = await params.client.messages.create({
|
const response = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
} as any);
|
} as any);
|
||||||
@@ -192,7 +232,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
|||||||
const response = await params.client.messages.create({
|
const response = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
@@ -248,6 +288,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
|||||||
|
|
||||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
if (!enabledTools.length) {
|
if (!enabledTools.length) {
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -259,7 +300,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
|||||||
const stream = await params.client.messages.create({
|
const stream = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -315,7 +356,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
|||||||
const stream = await params.client.messages.create({
|
const stream = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.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 { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
import type { ChatMessage, Provider } from "./types.js";
|
import type { ChatMessage, Provider } from "./types.js";
|
||||||
|
|
||||||
type ProviderAdapterParams = {
|
type ProviderAdapterParams = {
|
||||||
@@ -27,7 +28,7 @@ export type ProviderChatAdapter = {
|
|||||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||||
|
|
||||||
type ChatProtocol = {
|
type ChatProtocol = {
|
||||||
id: ChatProtocolId;
|
id: ChatProtocolId;
|
||||||
@@ -39,6 +40,7 @@ type ModelCatalogSpec = {
|
|||||||
enabled?: () => boolean;
|
enabled?: () => boolean;
|
||||||
fetchModels(client: any): Promise<string[]>;
|
fetchModels(client: any): Promise<string[]>;
|
||||||
fallbackModels?: () => string[];
|
fallbackModels?: () => string[];
|
||||||
|
sortModels?: (models: string[]) => string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderBackendSpec = {
|
type ProviderBackendSpec = {
|
||||||
@@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = {
|
|||||||
stream: streamWithMessagesApi,
|
stream: streamWithMessagesApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiProtocol: ChatProtocol = {
|
||||||
|
id: "gemini",
|
||||||
|
complete: completeWithGeminiApi,
|
||||||
|
stream: streamWithGeminiApi,
|
||||||
|
};
|
||||||
|
|
||||||
const responsesProtocol: ChatProtocol = {
|
const responsesProtocol: ChatProtocol = {
|
||||||
id: "responses",
|
id: "responses",
|
||||||
complete: completeWithResponsesApi,
|
complete: completeWithResponsesApi,
|
||||||
@@ -77,13 +85,48 @@ function modelIdsFromListResponse(page: any) {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripModelResourcePrefix(model: string) {
|
||||||
|
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyResponsesApiModel(model: string) {
|
function isLikelyResponsesApiModel(model: string) {
|
||||||
const id = model.toLowerCase();
|
const id = model.toLowerCase();
|
||||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
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("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("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||||
@@ -160,6 +203,29 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
createClient: geminiClient,
|
||||||
|
plainProtocol: geminiProtocol,
|
||||||
|
toolProtocol: geminiProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
||||||
|
url.searchParams.set("key", client.apiKey);
|
||||||
|
url.searchParams.set("pageSize", "1000");
|
||||||
|
const page = await fetchJson(url);
|
||||||
|
return Array.isArray(page?.models)
|
||||||
|
? page.models
|
||||||
|
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
||||||
|
.map((model: any) => model?.name)
|
||||||
|
.filter((id: unknown): id is string => typeof id === "string")
|
||||||
|
.map(stripModelResourcePrefix)
|
||||||
|
.filter(isLikelyGeminiChatModel)
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
sortModels: preferGeminiModels,
|
||||||
|
},
|
||||||
|
},
|
||||||
"hermes-agent": {
|
"hermes-agent": {
|
||||||
createClient: hermesAgentClient,
|
createClient: hermesAgentClient,
|
||||||
plainProtocol: chatCompletionsProtocol,
|
plainProtocol: chatCompletionsProtocol,
|
||||||
@@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] {
|
|||||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||||
const spec = backendSpecs[provider].modelCatalog;
|
const spec = backendSpecs[provider].modelCatalog;
|
||||||
if (!spec) return [];
|
if (!spec) return [];
|
||||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||||
|
return spec.sortModels ? spec.sortModels(models) : models;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const apiToPrismaProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
"hermes-agent": "hermes_agent",
|
"hermes-agent": "hermes_agent",
|
||||||
} as const satisfies Record<Provider, PrismaProvider>;
|
} as const satisfies Record<Provider, PrismaProvider>;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const prismaToApiProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
hermes_agent: "hermes-agent",
|
hermes_agent: "hermes-agent",
|
||||||
"hermes-agent": "hermes-agent",
|
"hermes-agent": "hermes-agent",
|
||||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenAI from "openai";
|
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import OpenAI from "openai";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
|
||||||
export function openaiClient() {
|
export function openaiClient() {
|
||||||
@@ -13,6 +13,14 @@ export function xaiClient() {
|
|||||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function 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() {
|
export function isHermesAgentConfigured() {
|
||||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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,7 +119,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
if (shouldPersist && chatId && call) {
|
if (shouldPersist && chatId && call) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.message.create({
|
await tx.message.create({
|
||||||
data: { chatId, role: "assistant" as any, content: text },
|
data: {
|
||||||
|
chatId,
|
||||||
|
role: "assistant" as any,
|
||||||
|
content: text,
|
||||||
|
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await tx.llmCall.update({
|
await tx.llmCall.update({
|
||||||
where: { id: call.id },
|
where: { id: call.id },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
|
||||||
|
|
||||||
export type Provider = (typeof PROVIDERS)[number];
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export type ChatMessage = {
|
|||||||
export type MultiplexRequest = {
|
export type MultiplexRequest = {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
persist?: boolean;
|
persist?: boolean;
|
||||||
|
clientRequestId?: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
|||||||
+361
-44
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
@@ -10,14 +11,17 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
|||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
|
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
|
import { searchWorkspace } from "./workspace-search.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
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));
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
@@ -88,13 +92,13 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
|||||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
|
|
||||||
const existing = await prisma.message.findMany({
|
const existing = await prisma.message.findMany({
|
||||||
where: { chatId },
|
where: { chatId },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
select: { role: true, content: true, name: true, metadata: true },
|
select: { role: true, content: true, name: true, metadata: true },
|
||||||
});
|
});
|
||||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||||
@@ -109,14 +113,21 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
|||||||
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
||||||
if (!toInsert.length) return;
|
if (!toInsert.length) return;
|
||||||
|
|
||||||
|
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
|
||||||
await prisma.message.createMany({
|
await prisma.message.createMany({
|
||||||
data: toInsert.map((m) => ({
|
data: toInsert.map((m, index) => {
|
||||||
chatId,
|
const metadata = {
|
||||||
role: m.role as any,
|
...(m.attachments?.length ? { attachments: m.attachments } : {}),
|
||||||
content: m.content,
|
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
|
||||||
name: m.name,
|
};
|
||||||
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
|
return {
|
||||||
})),
|
chatId,
|
||||||
|
role: m.role as any,
|
||||||
|
content: m.content,
|
||||||
|
name: m.name,
|
||||||
|
metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +180,7 @@ const CompletionStreamBody = z
|
|||||||
.object({
|
.object({
|
||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
persist: z.boolean().optional(),
|
persist: z.boolean().optional(),
|
||||||
|
clientRequestId: z.string().trim().min(1).max(128).optional(),
|
||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
@@ -186,8 +198,25 @@ const CompletionStreamBody = z
|
|||||||
path: ["chatId"],
|
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[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
if (!attachments?.length) return metadata as any;
|
if (!attachments?.length) return metadata as any;
|
||||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
@@ -302,6 +331,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
|||||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
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) {
|
async function generateChatTitle(content: string) {
|
||||||
const systemPrompt =
|
const systemPrompt =
|
||||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||||
@@ -399,6 +443,8 @@ function buildSseHeaders(originHeader: string | undefined) {
|
|||||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
|
const activeChatStreamRequestIds = new Map<string, string>();
|
||||||
|
const chatDeletionRoots = new Set<string>();
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
const STARRED_PROJECT_ID = "starred";
|
const STARRED_PROJECT_ID = "starred";
|
||||||
|
|
||||||
@@ -411,6 +457,8 @@ const starredProjectItemsSelect = {
|
|||||||
const chatSummarySelect = {
|
const chatSummarySelect = {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
titleGenerationPending: true,
|
||||||
|
parentChatId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
initiatedProvider: true,
|
initiatedProvider: true,
|
||||||
@@ -491,6 +539,29 @@ async function getSearchSummary(searchId: string) {
|
|||||||
return search ? serializeSearchLike(search) : null;
|
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) {
|
async function setChatStarred(chatId: string, starred: boolean) {
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||||
if (!exists) return null;
|
if (!exists) return null;
|
||||||
@@ -529,11 +600,7 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
|||||||
|
|
||||||
async function listWorkspaceItems() {
|
async function listWorkspaceItems() {
|
||||||
const [chats, searches] = await Promise.all([
|
const [chats, searches] = await Promise.all([
|
||||||
prisma.chat.findMany({
|
listRecentChatsWithRoots(),
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
}),
|
|
||||||
prisma.search.findMany({
|
prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -554,6 +621,7 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
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.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders?.();
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
@@ -588,10 +656,24 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
|||||||
return { event: ev.type, data: ev };
|
return { event: ev.type, data: ev };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||||
const stream = new ActiveSseStream();
|
const stream = new ActiveSseStream();
|
||||||
activeChatStreams.set(chatId, stream);
|
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 () => {
|
void (async () => {
|
||||||
let sawTerminalEvent = false;
|
let sawTerminalEvent = false;
|
||||||
try {
|
try {
|
||||||
@@ -611,11 +693,46 @@ function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionSt
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
} finally {
|
} finally {
|
||||||
activeChatStreams.delete(chatId);
|
clearActiveChatStream(chatId, stream);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
return stream;
|
function getMetadataClientRequestId(metadata: unknown) {
|
||||||
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||||
|
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||||
|
return typeof clientRequestId === "string" ? clientRequestId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
|
||||||
|
const assistantMessages = await prisma.message.findMany({
|
||||||
|
where: { chatId, role: "assistant" as any },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { content: true, metadata: true },
|
||||||
|
});
|
||||||
|
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeChatSubmissionStream(
|
||||||
|
stream: ActiveSseStream,
|
||||||
|
chatId: string,
|
||||||
|
body: z.infer<typeof CompletionStreamBody>,
|
||||||
|
assistantText: string
|
||||||
|
) {
|
||||||
|
stream.emit("meta", {
|
||||||
|
type: "meta",
|
||||||
|
chatId,
|
||||||
|
callId: null,
|
||||||
|
provider: body.provider,
|
||||||
|
model: body.model,
|
||||||
|
});
|
||||||
|
stream.complete({
|
||||||
|
event: "done",
|
||||||
|
data: {
|
||||||
|
type: "done",
|
||||||
|
text: assistantText,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||||
@@ -811,16 +928,43 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get("/v1/workspace-items", async (req) => {
|
app.get("/v1/workspace-items", async (req) => {
|
||||||
requireAdmin(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() };
|
return { items: await listWorkspaceItems() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await listRecentChatsWithRoots();
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -879,6 +1023,80 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { chat: serializeChatLike(chat) };
|
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) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
@@ -891,7 +1109,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.title !== undefined) data.title = body.title;
|
if (body.title !== undefined) {
|
||||||
|
data.title = body.title;
|
||||||
|
data.titleGenerationPending = false;
|
||||||
|
}
|
||||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||||
|
|
||||||
@@ -932,15 +1153,30 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
select: chatSummarySelect,
|
select: chatSummarySelect,
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
let suggestedRaw = "";
|
||||||
|
try {
|
||||||
|
suggestedRaw = await generateChatTitle(body.content);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn(
|
||||||
|
{
|
||||||
|
chatId: body.chatId,
|
||||||
|
err: getErrorMessage(err),
|
||||||
|
},
|
||||||
|
"chat title generation failed; using fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
await prisma.chat.updateMany({
|
||||||
where: { id: body.chatId, title: existing.title },
|
where: {
|
||||||
data: { title },
|
id: body.chatId,
|
||||||
|
title: existing.title,
|
||||||
|
titleGenerationPending: existing.titleGenerationPending,
|
||||||
|
},
|
||||||
|
data: { title, titleGenerationPending: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = await getChatSummary(body.chatId);
|
const chat = await getChatSummary(body.chatId);
|
||||||
@@ -956,14 +1192,46 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
req.log.info({ chatId }, "delete chat requested");
|
req.log.info({ chatId }, "delete chat requested");
|
||||||
|
|
||||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
const target = await prisma.chat.findUnique({
|
||||||
if (result.count === 0) {
|
where: { id: chatId },
|
||||||
|
select: { parentChatId: true },
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
req.log.warn({ chatId }, "delete chat target not found");
|
req.log.warn({ chatId }, "delete chat target not found");
|
||||||
return app.httpErrors.notFound("chat not found");
|
return app.httpErrors.notFound("chat not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
req.log.info({ chatId }, "chat deleted");
|
const familyRootId = target.parentChatId ?? chatId;
|
||||||
return { deleted: true };
|
if (chatDeletionRoots.has(familyRootId)) {
|
||||||
|
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
chatDeletionRoots.add(familyRootId);
|
||||||
|
try {
|
||||||
|
const familyIds = target.parentChatId
|
||||||
|
? [chatId]
|
||||||
|
: (
|
||||||
|
await prisma.chat.findMany({
|
||||||
|
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((chat) => chat.id);
|
||||||
|
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
||||||
|
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
||||||
|
return app.httpErrors.conflict("chat or fork has an active stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||||
|
if (result.count === 0) {
|
||||||
|
req.log.warn({ chatId }, "delete chat target no longer exists");
|
||||||
|
return app.httpErrors.notFound("chat not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.log.info({ chatId }, "chat deleted");
|
||||||
|
return { deleted: true };
|
||||||
|
} finally {
|
||||||
|
chatDeletionRoots.delete(familyRootId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/searches", async (req) => {
|
app.get("/v1/searches", async (req) => {
|
||||||
@@ -1253,7 +1521,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
include: {
|
include: {
|
||||||
messages: { orderBy: { createdAt: "asc" } },
|
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||||
calls: { orderBy: { createdAt: "desc" } },
|
calls: { orderBy: { createdAt: "desc" } },
|
||||||
projectItems: starredProjectItemsSelect,
|
projectItems: starredProjectItemsSelect,
|
||||||
},
|
},
|
||||||
@@ -1339,7 +1607,28 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming SSE endpoint.
|
// 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.
|
||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
@@ -1347,23 +1636,51 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// Ensure the chat exists and identify its family before reserving a stream.
|
||||||
|
let chatFamilyRootId: string | null = null;
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
const exists = await prisma.chat.findUnique({
|
||||||
|
where: { id: body.chatId },
|
||||||
|
select: { id: true, parentChatId: true },
|
||||||
|
});
|
||||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
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) {
|
if (body.persist !== false && body.chatId) {
|
||||||
if (activeChatStreams.has(body.chatId)) {
|
const activeStream = activeChatStreams.get(body.chatId);
|
||||||
|
if (activeStream) {
|
||||||
|
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
||||||
|
return streamActiveRun(req, reply, activeStream);
|
||||||
|
}
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
|
||||||
return streamActiveRun(req, reply, stream);
|
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
||||||
|
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||||
|
try {
|
||||||
|
if (body.clientRequestId) {
|
||||||
|
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||||
|
if (completedSubmission) {
|
||||||
|
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||||
|
clearActiveChatStream(body.chatId, reservedStream);
|
||||||
|
return streamActiveRun(req, reply, reservedStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||||
|
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||||
|
const configuredBody = await applyStoredChatSettings(body);
|
||||||
|
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||||
|
return streamActiveRun(req, reply, reservedStream);
|
||||||
|
} catch (err) {
|
||||||
|
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
|
clearActiveChatStream(body.chatId, reservedStream);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
||||||
|
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
||||||
|
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
||||||
|
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
||||||
|
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
||||||
|
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
||||||
|
const MAX_RATE_LIMIT_RETRIES = 3;
|
||||||
|
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
||||||
|
|
||||||
|
type RateLimitPolicy = {
|
||||||
|
limit: number;
|
||||||
|
windowSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||||
|
let lastRequestAtMs = 0;
|
||||||
|
let nextRequestAtMs = 0;
|
||||||
|
let quotaUnavailableUntilMs = 0;
|
||||||
|
let requestQueue = Promise.resolve();
|
||||||
|
|
||||||
|
export type BraveSearchOptions = {
|
||||||
|
numResults: number;
|
||||||
|
includeDomains?: string[];
|
||||||
|
excludeDomains?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraveSearchResult = {
|
||||||
|
title: string | null;
|
||||||
|
url: string | null;
|
||||||
|
publishedDate: string | null;
|
||||||
|
author: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
text: string | null;
|
||||||
|
highlights: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraveSearchResponse = {
|
||||||
|
query: string;
|
||||||
|
requestId: string | null;
|
||||||
|
results: BraveSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function clipText(input: string, maxCharacters: number) {
|
||||||
|
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactWhitespace(input: string) {
|
||||||
|
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBraveSearchApiKey() {
|
||||||
|
if (!env.BRAVE_SEARCH_API_KEY) {
|
||||||
|
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
||||||
|
}
|
||||||
|
return env.BRAVE_SEARCH_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(milliseconds: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIntervalSafety(intervalMs: number) {
|
||||||
|
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommaSeparatedNumbers(value: string | null) {
|
||||||
|
if (!value) return [];
|
||||||
|
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
||||||
|
if (!value) return [];
|
||||||
|
return value.split(",").flatMap((part) => {
|
||||||
|
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
||||||
|
if (!match) return [];
|
||||||
|
const limit = Number(match[1]);
|
||||||
|
const windowSeconds = Number(match[2]);
|
||||||
|
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
||||||
|
if (!policies.length) return null;
|
||||||
|
let burstIndex = 0;
|
||||||
|
for (let index = 1; index < policies.length; index += 1) {
|
||||||
|
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
||||||
|
}
|
||||||
|
return burstIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRateLimitState(headers: Headers) {
|
||||||
|
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
||||||
|
const burstIndex = getBurstPolicyIndex(policies);
|
||||||
|
if (burstIndex === null) return;
|
||||||
|
|
||||||
|
const burstPolicy = policies[burstIndex]!;
|
||||||
|
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
||||||
|
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
||||||
|
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
||||||
|
}
|
||||||
|
requestIntervalMs = learnedIntervalMs;
|
||||||
|
|
||||||
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||||
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||||
|
for (let index = 0; index < policies.length; index += 1) {
|
||||||
|
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
||||||
|
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
||||||
|
if (index === burstIndex) {
|
||||||
|
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
||||||
|
} else {
|
||||||
|
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLongTermQuotaAvailable() {
|
||||||
|
if (quotaUnavailableUntilMs <= Date.now()) {
|
||||||
|
quotaUnavailableUntilMs = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
||||||
|
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRateLimitSlot() {
|
||||||
|
const reservation = requestQueue.then(async () => {
|
||||||
|
while (true) {
|
||||||
|
assertLongTermQuotaAvailable();
|
||||||
|
const waitMs = nextRequestAtMs - Date.now();
|
||||||
|
if (waitMs <= 0) break;
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
lastRequestAtMs = Date.now();
|
||||||
|
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
||||||
|
});
|
||||||
|
requestQueue = reservation.catch(() => undefined);
|
||||||
|
await reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
||||||
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||||
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||||
|
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
||||||
|
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
||||||
|
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
||||||
|
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
||||||
|
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBrave(url: URL) {
|
||||||
|
const apiKey = requireBraveSearchApiKey();
|
||||||
|
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
||||||
|
await waitForRateLimitSlot();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
...buildBrowserLikeRequestHeaders("application/json"),
|
||||||
|
"X-Subscription-Token": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRateLimitState(response.headers);
|
||||||
|
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
||||||
|
|
||||||
|
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
||||||
|
await response.arrayBuffer();
|
||||||
|
if (retryDelayMs === null) {
|
||||||
|
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
||||||
|
}
|
||||||
|
await sleep(retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Brave Search API request failed after rate-limit retries.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(input: string) {
|
||||||
|
const trimmed = input.trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||||
|
return parsed.hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomains(input: string[] | undefined) {
|
||||||
|
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
||||||
|
if (!urlRaw) return false;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
||||||
|
const includeDomains = normalizeDomains(options.includeDomains);
|
||||||
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||||
|
return results.filter((result) => {
|
||||||
|
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||||
|
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
||||||
|
const includeDomains = normalizeDomains(options.includeDomains);
|
||||||
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||||
|
const includeClause =
|
||||||
|
includeDomains.length === 0
|
||||||
|
? ""
|
||||||
|
: includeDomains.length === 1
|
||||||
|
? `site:${includeDomains[0]}`
|
||||||
|
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
||||||
|
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
||||||
|
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
||||||
|
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
||||||
|
url.searchParams.set("q", buildBraveQuery(query, options));
|
||||||
|
url.searchParams.set("count", String(options.numResults));
|
||||||
|
url.searchParams.set("safesearch", "moderate");
|
||||||
|
url.searchParams.set("result_filter", "web");
|
||||||
|
url.searchParams.set("text_decorations", "false");
|
||||||
|
url.searchParams.set("extra_snippets", "true");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = compactWhitespace(value);
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWebResult(result: any): BraveSearchResult {
|
||||||
|
const description = stringOrNull(result?.description);
|
||||||
|
const extraSnippets = stringArray(result?.extra_snippets);
|
||||||
|
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
||||||
|
const combinedText = snippets.join("\n\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: stringOrNull(result?.title),
|
||||||
|
url: stringOrNull(result?.url),
|
||||||
|
publishedDate: stringOrNull(result?.page_age),
|
||||||
|
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
||||||
|
summary: description ? clipText(description, 1_400) : null,
|
||||||
|
text: combinedText ? clipText(combinedText, 700) : null,
|
||||||
|
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
||||||
|
const url = buildSearchUrl(query, options);
|
||||||
|
const response = await fetchBrave(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.arrayBuffer();
|
||||||
|
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
await response.arrayBuffer();
|
||||||
|
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
requestId: response.headers.get("x-request-id"),
|
||||||
|
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBraveRateLimitStateForTests() {
|
||||||
|
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||||
|
lastRequestAtMs = 0;
|
||||||
|
nextRequestAtMs = 0;
|
||||||
|
quotaUnavailableUntilMs = 0;
|
||||||
|
requestQueue = Promise.resolve();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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; }
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { env } from "../src/env.js";
|
||||||
|
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
|
||||||
|
|
||||||
|
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ input, init });
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
web: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: " Brave result ",
|
||||||
|
url: "https://docs.example.com/article",
|
||||||
|
description: "Main\n snippet",
|
||||||
|
extra_snippets: ["Extra snippet one", "Extra snippet two"],
|
||||||
|
page_age: "2026-07-18T12:00:00Z",
|
||||||
|
profile: { name: "Example Docs" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Excluded result",
|
||||||
|
url: "https://blocked.example.com/article",
|
||||||
|
description: "Should be filtered",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"x-request-id": "brave-request-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchBrave("latest docs", {
|
||||||
|
numResults: 5,
|
||||||
|
includeDomains: ["https://example.com/path"],
|
||||||
|
excludeDomains: ["blocked.example.com"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
const requestUrl = new URL(String(fetchCalls[0]?.input));
|
||||||
|
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
|
||||||
|
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
|
||||||
|
assert.equal(requestUrl.searchParams.get("count"), "5");
|
||||||
|
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
|
||||||
|
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
|
||||||
|
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
|
||||||
|
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
|
||||||
|
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
|
||||||
|
|
||||||
|
assert.deepEqual(response, {
|
||||||
|
query: "latest docs",
|
||||||
|
requestId: "brave-request-1",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Brave result",
|
||||||
|
url: "https://docs.example.com/article",
|
||||||
|
publishedDate: "2026-07-18T12:00:00Z",
|
||||||
|
author: "Example Docs",
|
||||||
|
summary: "Main snippet",
|
||||||
|
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
|
||||||
|
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave rejects requests without an API key", async () => {
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = undefined;
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
|
||||||
|
} finally {
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave reports non-JSON responses", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response("upstream error", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("test", { numResults: 1 }),
|
||||||
|
/Brave Search API returned text\/plain/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const requestStartedAt: number[] = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
requestStartedAt.push(Date.now());
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "1, 1999",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
searchBrave("burst one", { numResults: 1 }),
|
||||||
|
searchBrave("burst two", { numResults: 1 }),
|
||||||
|
searchBrave("burst three", { numResults: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(requestStartedAt.length, 3);
|
||||||
|
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
|
||||||
|
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const requestStartedAt: number[] = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
requestStartedAt.push(Date.now());
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "49, 0",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchBrave("learn upgraded policy", { numResults: 1 });
|
||||||
|
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
|
||||||
|
|
||||||
|
assert.equal(requestStartedAt.length, 9);
|
||||||
|
const burstStartedAt = requestStartedAt.slice(1);
|
||||||
|
for (let index = 1; index < burstStartedAt.length; index += 1) {
|
||||||
|
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
|
||||||
|
}
|
||||||
|
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave retries 429 responses after the burst window resets", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
let fetchCount = 0;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
const rateLimitHeaders = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
};
|
||||||
|
if (fetchCount === 1) {
|
||||||
|
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
|
||||||
|
status: 429,
|
||||||
|
headers: rateLimitHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await searchBrave("retry burst", { numResults: 1 });
|
||||||
|
assert.equal(fetchCount, 2);
|
||||||
|
assert.ok(Date.now() - startedAt >= 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave does not wait for exhausted long-term quotas", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "0, 0",
|
||||||
|
"x-ratelimit-reset": "1, 100000",
|
||||||
|
},
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("quota exhausted", { numResults: 1 }),
|
||||||
|
/rate limit quota is exhausted beyond the retry window/
|
||||||
|
);
|
||||||
|
assert.ok(Date.now() - startedAt < 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
let fetchCount = 0;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "0, 0",
|
||||||
|
"x-ratelimit-reset": "1, 100000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchBrave("last allowed query", { numResults: 1 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("over quota query", { numResults: 1 }),
|
||||||
|
/long-term quota is exhausted/
|
||||||
|
);
|
||||||
|
assert.equal(fetchCount, 1);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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,6 +140,94 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("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 () => {
|
test("fetch_url sends browser-like navigation headers", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
|||||||
@@ -1,6 +1,50 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||||
@@ -27,6 +71,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
|
|||||||
managedTools: true,
|
managedTools: true,
|
||||||
enabledTools: ["web_search"],
|
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"]), {
|
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||||
provider: "hermes-agent",
|
provider: "hermes-agent",
|
||||||
protocol: "chat-completions",
|
protocol: "chat-completions",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
|||||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
assert.equal(toPrismaProvider("gemini"), "gemini");
|
||||||
|
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||||
initiatedProvider: "hermes-agent",
|
initiatedProvider: "hermes-agent",
|
||||||
lastUsedProvider: "xai",
|
lastUsedProvider: "gemini",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
+45
-22
@@ -42,12 +42,13 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
"hermes-agent": ["hermes-agent"],
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
anthropic: { models: [], loadedAt: null, error: null },
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
|
gemini: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeTags(value: string) {
|
function escapeTags(value: string) {
|
||||||
@@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "gemini") return "Gemini";
|
||||||
if (provider === "hermes-agent") return "Hermes Agent";
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -124,21 +127,27 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
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) => {
|
return items.map((item) => {
|
||||||
if (item.type === "chat") {
|
if (item.type === "chat") {
|
||||||
const chat = item;
|
const chat = item;
|
||||||
|
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
|
||||||
return {
|
return {
|
||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
starred: chat.starred,
|
starred: starOwner.starred,
|
||||||
starredAt: chat.starredAt,
|
starredAt: starOwner.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +275,7 @@ async function main() {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
gemini: null,
|
||||||
"hermes-agent": null,
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||||
@@ -975,11 +985,11 @@ async function main() {
|
|||||||
focusComposer();
|
focusComposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeSuggestTitle(chatId: string, content: string) {
|
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
||||||
const chatSummary = chats.find((chat) => chat.id === chatId);
|
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
||||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
||||||
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
|
|
||||||
|
|
||||||
|
const chatId = chat.id;
|
||||||
pendingTitleGeneration.add(chatId);
|
pendingTitleGeneration.add(chatId);
|
||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
@@ -989,6 +999,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1045,6 +1057,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
|
parentChatId: chat.parentChatId,
|
||||||
|
titleGenerationPending: chat.titleGenerationPending,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -1064,13 +1078,13 @@ async function main() {
|
|||||||
throw new Error("Unable to initialize chat");
|
throw new Error("Unable to initialize chat");
|
||||||
}
|
}
|
||||||
|
|
||||||
void maybeSuggestTitle(chatId, content);
|
|
||||||
|
|
||||||
let baseChat = selectedChat;
|
let baseChat = selectedChat;
|
||||||
if (!baseChat || baseChat.id !== chatId) {
|
if (!baseChat || baseChat.id !== chatId) {
|
||||||
baseChat = await api.getChat(chatId);
|
baseChat = await api.getChat(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void maybeSuggestTitle(baseChat, content);
|
||||||
|
|
||||||
const requestMessages: CompletionRequestMessage[] = [
|
const requestMessages: CompletionRequestMessage[] = [
|
||||||
...baseChat.messages
|
...baseChat.messages
|
||||||
.filter((message) => !isToolCallLogMessage(message))
|
.filter((message) => !isToolCallLogMessage(message))
|
||||||
@@ -1388,6 +1402,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
initiatedProvider: updated.initiatedProvider,
|
initiatedProvider: updated.initiatedProvider,
|
||||||
initiatedModel: updated.initiatedModel,
|
initiatedModel: updated.initiatedModel,
|
||||||
@@ -1401,12 +1417,16 @@ async function main() {
|
|||||||
async function handleToggleStarSelection() {
|
async function handleToggleStarSelection() {
|
||||||
if (!selectedItem) return;
|
if (!selectedItem) return;
|
||||||
|
|
||||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
|
||||||
const nextStarred = !currentItem?.starred;
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (selectedItem.kind === "chat") {
|
if (selectedItem.kind === "chat") {
|
||||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
|
||||||
|
const selectedParentChatId = selectedChat?.id === selectedItem.id
|
||||||
|
? selectedChat.parentChatId
|
||||||
|
: selectedSummary?.parentChatId;
|
||||||
|
const rootChatId = selectedParentChatId ?? selectedItem.id;
|
||||||
|
const rootSummary = chats.find((chat) => chat.id === rootChatId);
|
||||||
|
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
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));
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
@@ -1417,6 +1437,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1427,7 +1449,8 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
||||||
|
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
||||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
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));
|
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -13,6 +13,8 @@ export type ModelCatalogResponse = {
|
|||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -68,6 +70,8 @@ export type ToolCallEvent = {
|
|||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
|
|||||||
## UI
|
## UI
|
||||||
|
|
||||||
- Left panel: mixed list of chat conversations and Exa searches.
|
- 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:
|
- Right panel:
|
||||||
- Chat mode: transcript + provider/model controls.
|
- Chat mode: transcript + provider/model controls.
|
||||||
- Search mode: top AI answer block + Google-style Exa results view.
|
- Search mode: top AI answer block + Google-style Exa results view.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "node --test --experimental-strip-types tests/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+16
-2
@@ -3,10 +3,24 @@ self.addEventListener("install", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(self.clients.claim());
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
await self.clients.claim();
|
||||||
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
await Promise.all(
|
||||||
|
windows.map(async (client) => {
|
||||||
|
try {
|
||||||
|
await client.navigate(client.url);
|
||||||
|
} catch {
|
||||||
|
// The client may have closed while the new worker was activating.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.mode !== "navigate") return;
|
if (event.request.mode !== "navigate") return;
|
||||||
event.respondWith(fetch(event.request));
|
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||||
});
|
});
|
||||||
|
|||||||
+1286
-503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
|||||||
|
.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__tool {
|
||||||
|
margin:0 0 0 35px;
|
||||||
|
max-width:calc(89% - 35px);
|
||||||
|
border:1px solid hsl(256 35% 47%/.23);
|
||||||
|
border-radius:11px;
|
||||||
|
background:hsl(246 32% 12%/.56);
|
||||||
|
color:hsl(255 26% 75%);
|
||||||
|
font-size:11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool summary {
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:9px;
|
||||||
|
min-height:37px;
|
||||||
|
padding:8px 11px;
|
||||||
|
cursor:pointer;
|
||||||
|
list-style:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool summary::-webkit-details-marker {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-icon {
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
color:hsl(267 69% 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-title {
|
||||||
|
min-width:0;
|
||||||
|
flex:1;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
text-transform:capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-state {
|
||||||
|
color:hsl(254 18% 51%);
|
||||||
|
font-size:9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool.is-running .agent-room__tool-state {
|
||||||
|
color:hsl(184 54% 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool.is-failed .agent-room__tool-state {
|
||||||
|
color:hsl(351 71% 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-chevron {
|
||||||
|
color:hsl(255 20% 52%);
|
||||||
|
transition:transform .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool[open] .agent-room__tool-chevron {
|
||||||
|
transform:rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-body {
|
||||||
|
padding:0 11px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__tool-body pre {
|
||||||
|
max-height:210px;
|
||||||
|
overflow:auto;
|
||||||
|
white-space:pre-wrap;
|
||||||
|
overflow-wrap:anywhere;
|
||||||
|
font:10px/1.6 ui-monospace,monospace;
|
||||||
|
margin:8px 0 0;
|
||||||
|
padding:9px;
|
||||||
|
border-radius:6px;
|
||||||
|
background:hsl(239 42% 6%/.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__tool {
|
||||||
|
margin-left:30px;
|
||||||
|
max-width:calc(96% - 30px);
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact";
|
||||||
|
import type { Message } from "@/lib/api";
|
||||||
|
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||||
|
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 AgentToolCard({ message }: { message: Message }) {
|
||||||
|
const info = metadata(message);
|
||||||
|
const active = info.status === "initiated";
|
||||||
|
const failed = info.status === "failed";
|
||||||
|
const title = toolName(info.toolName || message.name);
|
||||||
|
const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : "";
|
||||||
|
return (
|
||||||
|
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
|
||||||
|
<summary>
|
||||||
|
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
|
||||||
|
<span className="agent-room__tool-title">{title}</span>
|
||||||
|
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
|
||||||
|
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div className="agent-room__tool-body">
|
||||||
|
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
|
||||||
|
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
|
||||||
|
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
let previousDay = "";
|
||||||
|
for (const message of room.messages) {
|
||||||
|
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") {
|
||||||
|
enteringMessageIdsRef.current.add(message.id);
|
||||||
|
}
|
||||||
|
seenMessageIdsRef.current.add(message.id);
|
||||||
|
}
|
||||||
|
if (!room.isLoading) historyReadyRef.current = true;
|
||||||
|
return room.messages.filter(message => message.role !== "system").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}
|
||||||
|
{message.role === "tool" ? <AgentToolCard message={message} /> : <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