Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272ad0bbf0 | ||
|
|
de7b448bc5 | ||
|
|
3c7fc51fdb | ||
|
|
0062f37b9f | ||
|
|
0ae551615f | ||
|
|
88bef50ae7 | ||
|
|
0d069b4233 | ||
|
|
60bbe077e8 | ||
|
|
0b09d5425b | ||
|
|
c9a3015e35 | ||
|
|
abd8a80daa | ||
|
|
0f76ef91a9 | ||
|
|
72e2ffd898 | ||
|
|
4c610c89e1 | ||
|
|
477921563f | ||
|
|
0fca0e93ec | ||
|
|
f977f9943c | ||
|
|
f445730a41 | ||
|
|
76cb808c33 | ||
|
|
e167bd983f | ||
|
|
e4dd91564f | ||
|
|
3bfde476a6 | ||
|
|
b8676027db | ||
|
|
d36d2c60a3 | ||
|
|
3d7031bb40 | ||
|
|
fa9b725c77 | ||
|
|
a88987d08d | ||
|
|
e137ea1077 | ||
|
|
fad25d7f2b | ||
|
|
fb28508764 | ||
|
|
4365798f5e |
@@ -0,0 +1,289 @@
|
||||
name: TestFlight Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
runs-on: xcode
|
||||
env:
|
||||
SIGNING_KEYCHAIN: sybil_signing_temp
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate release tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag_name="${GITHUB_REF#refs/tags/}"
|
||||
if [[ ! "$tag_name" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Release tag must match release/vN.N.N; got ${tag_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="${tag_name#release/v}"
|
||||
{
|
||||
echo "TAG_NAME=${tag_name}"
|
||||
echo "RELEASE_VERSION=${release_version}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
- name: Install Ruby gems
|
||||
working-directory: ios
|
||||
run: bundle install
|
||||
|
||||
- name: Install release tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing_tools=()
|
||||
for tool in xcodegen jq; do
|
||||
if ! command -v "${tool}" >/dev/null 2>&1; then
|
||||
missing_tools+=("${tool}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${#missing_tools[@]}" -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "Missing required tools: ${missing_tools[*]}; Homebrew is not available to install them" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
brew install "${missing_tools[@]}"
|
||||
|
||||
- name: Install signing secrets
|
||||
env:
|
||||
APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
|
||||
APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
|
||||
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}"
|
||||
: "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}"
|
||||
: "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}"
|
||||
|
||||
keychain_password="$(uuidgen)"
|
||||
previous_default_keychain="$(security default-keychain -d user | sed 's/[ "]//g' || true)"
|
||||
if [[ "${previous_default_keychain}" == *"/${SIGNING_KEYCHAIN}"* || ! -e "${previous_default_keychain}" ]]; then
|
||||
previous_default_keychain=""
|
||||
fi
|
||||
developer_dir="$(xcode-select -p)"
|
||||
signing_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/sybil-signing.XXXXXX")"
|
||||
mkdir -p "${HOME}/Library/Keychains"
|
||||
keychain_name="${HOME}/Library/Keychains/login.keychain"
|
||||
certificate_path="${signing_dir}/appstore-signing.p12"
|
||||
wwdr_certificate_path="${signing_dir}/AppleWWDRCAG3.cer"
|
||||
profile_path="${signing_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||
profile_plist="${signing_dir}/profile.plist"
|
||||
old_profile_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
|
||||
xcode_profile_dir="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
mkdir -p "${old_profile_dir}" "${xcode_profile_dir}"
|
||||
|
||||
printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > "${certificate_path}"
|
||||
printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${profile_path}"
|
||||
curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -o "${wwdr_certificate_path}"
|
||||
openssl smime -inform DER -verify -noverify -in "${profile_path}" -out "${profile_plist}" >/dev/null
|
||||
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "${profile_plist}")"
|
||||
profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "${profile_plist}")"
|
||||
old_profile_path="${old_profile_dir}/${profile_uuid}.mobileprovision"
|
||||
xcode_profile_path="${xcode_profile_dir}/${profile_uuid}.mobileprovision"
|
||||
old_named_profile_path="${old_profile_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||
xcode_named_profile_path="${xcode_profile_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||
cp "${profile_path}" "${old_profile_path}"
|
||||
cp "${profile_path}" "${xcode_profile_path}"
|
||||
cp "${profile_path}" "${old_named_profile_path}"
|
||||
cp "${profile_path}" "${xcode_named_profile_path}"
|
||||
|
||||
base_keychains=()
|
||||
while IFS= read -r existing_keychain; do
|
||||
[[ -z "${existing_keychain}" ]] && continue
|
||||
[[ "${existing_keychain}" == *"/${SIGNING_KEYCHAIN}"* ]] && continue
|
||||
[[ ! -e "${existing_keychain}" ]] && continue
|
||||
base_keychains+=("${existing_keychain}")
|
||||
done < <(security list-keychains -d user | sed 's/[ "]//g')
|
||||
|
||||
security delete-keychain "${keychain_name}" >/dev/null 2>&1 || true
|
||||
rm -f "${HOME}/Library/Keychains/${keychain_name}-db"
|
||||
security create-keychain -p "${keychain_password}" "${keychain_name}"
|
||||
security set-keychain-settings -lut 21600 "${keychain_name}"
|
||||
security unlock-keychain -p "${keychain_password}" "${keychain_name}"
|
||||
security import "${wwdr_certificate_path}" \
|
||||
-k "${keychain_name}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/xcodebuild
|
||||
security import "${certificate_path}" \
|
||||
-k "${keychain_name}" \
|
||||
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/xcodebuild \
|
||||
-T "${developer_dir}/usr/bin/xcodebuild"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_name}"
|
||||
if [[ "${#base_keychains[@]}" -gt 0 ]]; then
|
||||
security list-keychains -d user -s "${keychain_name}" "${base_keychains[@]}"
|
||||
security list-keychains -s "${keychain_name}" "${base_keychains[@]}"
|
||||
else
|
||||
security list-keychains -d user -s "${keychain_name}"
|
||||
security list-keychains -s "${keychain_name}"
|
||||
fi
|
||||
security default-keychain -d user -s "${keychain_name}"
|
||||
keychain_path="$(security list-keychains -d user | sed 's/[ "]//g' | head -n 1)"
|
||||
security find-identity -v -p codesigning "${keychain_path}"
|
||||
security find-identity -v -p codesigning
|
||||
echo "Installed ${profile_name} (${profile_uuid}) provisioning profile"
|
||||
{
|
||||
echo "SYBIL_SIGNING_KEYCHAIN_PATH=${keychain_path}"
|
||||
echo "SYBIL_SIGNING_KEYCHAIN_NAME=${keychain_name}"
|
||||
echo "SYBIL_SIGNING_KEYCHAIN_PASSWORD=${keychain_password}"
|
||||
echo "SYBIL_PREVIOUS_DEFAULT_KEYCHAIN=${previous_default_keychain}"
|
||||
echo "SYBIL_PROVISIONING_PROFILE_UUID=${profile_uuid}"
|
||||
echo "SYBIL_SIGNING_DIR=${signing_dir}"
|
||||
echo "SYBIL_OLD_PROFILE_PATH=${old_profile_path}"
|
||||
echo "SYBIL_XCODE_PROFILE_PATH=${xcode_profile_path}"
|
||||
echo "SYBIL_OLD_NAMED_PROFILE_PATH=${old_named_profile_path}"
|
||||
echo "SYBIL_XCODE_NAMED_PROFILE_PATH=${xcode_named_profile_path}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64: "true"
|
||||
FASTLANE_DONT_STORE_PASSWORD: "1"
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
security unlock-keychain -p "${SYBIL_SIGNING_KEYCHAIN_PASSWORD}" "${SYBIL_SIGNING_KEYCHAIN_PATH}"
|
||||
security list-keychains -d user -s "${SYBIL_SIGNING_KEYCHAIN_PATH}" $(security list-keychains -d user | sed 's/[ "]//g')
|
||||
security default-keychain -d user -s "${SYBIL_SIGNING_KEYCHAIN_PATH}"
|
||||
security list-keychains -s "${SYBIL_SIGNING_KEYCHAIN_PATH}" $(security list-keychains | sed 's/[ "]//g')
|
||||
security find-identity -v -p codesigning "${SYBIL_SIGNING_KEYCHAIN_PATH}"
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta
|
||||
|
||||
- name: Locate IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ipa_path="$(find ios/build/fastlane -maxdepth 1 -type f -name '*.ipa' -print | sort | tail -n 1)"
|
||||
if [[ -z "${ipa_path}" ]]; then
|
||||
echo "No IPA found under ios/build/fastlane" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "IPA_PATH=${ipa_path}"
|
||||
echo "IPA_NAME=$(basename "${ipa_path}")"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Publish Gitea release asset
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_API_URL: ${{ github.api_url }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
|
||||
api_url="${RELEASE_API_URL:-https://code.buzzert.dev/api/v1}"
|
||||
repository="${RELEASE_REPOSITORY:-buzzert/Sybil-2}"
|
||||
sha="${RELEASE_SHA:-${GITHUB_SHA:-}}"
|
||||
release_name="Sybil v${RELEASE_VERSION}"
|
||||
release_body="Automated TestFlight release for ${TAG_NAME}."
|
||||
release_payload="$(jq -nc \
|
||||
--arg tag "${TAG_NAME}" \
|
||||
--arg name "${release_name}" \
|
||||
--arg body "${release_body}" \
|
||||
--arg target "${sha}" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false} +
|
||||
(if $target == "" then {} else {target_commitish: $target} end)')"
|
||||
|
||||
response_file="$(mktemp)"
|
||||
status="$(curl -sS -o "${response_file}" -w "%{http_code}" \
|
||||
-X POST "${api_url}/repos/${repository}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${release_payload}")"
|
||||
|
||||
if [[ "${status}" == "201" ]]; then
|
||||
release_id="$(jq -r '.id' "${response_file}")"
|
||||
elif [[ "${status}" == "409" ]]; then
|
||||
release_id="$(curl -fsS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases?limit=100" |
|
||||
jq -r --arg tag "${TAG_NAME}" '.[] | select(.tag_name == $tag) | .id' |
|
||||
head -n 1)"
|
||||
else
|
||||
cat "${response_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
|
||||
echo "Could not resolve Gitea release id for ${TAG_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing_asset_id="$(curl -fsS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets" |
|
||||
jq -r --arg name "${IPA_NAME}" '.[] | select(.name == $name) | .id' |
|
||||
head -n 1)"
|
||||
|
||||
if [[ -n "${existing_asset_id}" && "${existing_asset_id}" != "null" ]]; then
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets/${existing_asset_id}"
|
||||
fi
|
||||
|
||||
asset_name="$(jq -rn --arg value "${IPA_NAME}" '$value | @uri')"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${IPA_PATH}" \
|
||||
"${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
|
||||
|
||||
echo "Published ${IPA_NAME} to ${release_name}"
|
||||
|
||||
- name: Clean up temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -n "${SYBIL_PREVIOUS_DEFAULT_KEYCHAIN:-}" ]]; then
|
||||
security default-keychain -d user -s "${SYBIL_PREVIOUS_DEFAULT_KEYCHAIN}" || true
|
||||
fi
|
||||
rm -f \
|
||||
"${SYBIL_OLD_PROFILE_PATH:-}" \
|
||||
"${SYBIL_XCODE_PROFILE_PATH:-}" \
|
||||
"${SYBIL_OLD_NAMED_PROFILE_PATH:-}" \
|
||||
"${SYBIL_XCODE_NAMED_PROFILE_PATH:-}"
|
||||
security delete-keychain "${SYBIL_SIGNING_KEYCHAIN_PATH:-${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db}" || true
|
||||
rm -f "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}-"*.keychain-db
|
||||
rm -rf "${SYBIL_SIGNING_DIR:-}"
|
||||
@@ -1,39 +0,0 @@
|
||||
name: TestFlight
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/ios/v*.*.*"
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
name: Build and upload
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Check out the release tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3.11"
|
||||
bundler-cache: true
|
||||
working-directory: ios
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: ios
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY: ${{ secrets.ASC_KEY }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
CI: "true"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
run: bundle exec fastlane ios beta
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
.env
|
||||
ios/fastlane/README.md
|
||||
ios/fastlane/report.xml
|
||||
|
||||
|
||||
Vendored
-32
@@ -1,8 +1,3 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -17,44 +12,17 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://server:8787/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
|
||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||
|
||||
+14
-195
@@ -23,8 +23,6 @@ Chat upload limits:
|
||||
|
||||
### `GET /v1/auth/session`
|
||||
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
|
||||
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
|
||||
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
|
||||
|
||||
## Models
|
||||
|
||||
@@ -33,16 +31,14 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
|
||||
@@ -60,7 +56,7 @@ Chat upload limits:
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
@@ -83,8 +79,6 @@ Behavior notes:
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
|
||||
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
@@ -93,8 +87,6 @@ Behavior notes:
|
||||
"type": "chat",
|
||||
"id": "chat-id",
|
||||
"title": "optional title",
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": true,
|
||||
@@ -123,19 +115,9 @@ Behavior notes:
|
||||
Behavior notes:
|
||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||
- The response currently combines up to 100 chats and up to 100 searches.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
History search (`q`) notes:
|
||||
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
|
||||
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
|
||||
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
|
||||
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
|
||||
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
|
||||
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
|
||||
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -146,7 +128,7 @@ History search (`q`) notes:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
@@ -164,32 +146,15 @@ History search (`q`) notes:
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||
|
||||
### `POST /v1/chats/:chatId/fork`
|
||||
- Body: `{ "messageId"?: string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Source chat not found: `404 { "message": "chat not found" }`
|
||||
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
|
||||
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
|
||||
|
||||
Behavior notes:
|
||||
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
||||
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
||||
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
||||
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
||||
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
||||
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
|
||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||
@@ -216,19 +181,13 @@ Behavior notes:
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
Behavior notes:
|
||||
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
||||
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
|
||||
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
|
||||
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
|
||||
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
|
||||
|
||||
### `GET /v1/chats/:chatId`
|
||||
- Response: `{ "chat": ChatDetail }`
|
||||
@@ -275,7 +234,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -326,7 +285,6 @@ Behavior notes:
|
||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||
@@ -335,16 +293,13 @@ Behavior notes:
|
||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
|
||||
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
|
||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||
@@ -362,16 +317,6 @@ Behavior notes:
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
|
||||
## Streaming Chat
|
||||
|
||||
### `POST /v1/chat-completions/stream`
|
||||
|
||||
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
|
||||
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
|
||||
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
|
||||
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
|
||||
|
||||
## Searches
|
||||
|
||||
### `GET /v1/searches`
|
||||
@@ -467,15 +412,13 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
@@ -521,15 +464,13 @@ Behavior notes:
|
||||
{
|
||||
"id": "...",
|
||||
"title": null,
|
||||
"parentChatId": null,
|
||||
"titleGenerationPending": false,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
@@ -564,125 +505,3 @@ Behavior notes:
|
||||
```
|
||||
|
||||
For streaming contracts, see `docs/api/streaming-chat.md`.
|
||||
|
||||
## Sybil Agent Room
|
||||
|
||||
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
|
||||
background work, and history; these endpoints do not create ordinary Sybil chats or
|
||||
copy the transcript into the Sybil message database. All endpoints use normal Sybil
|
||||
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
|
||||
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
|
||||
|
||||
### `GET /v1/agent`
|
||||
|
||||
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
|
||||
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
|
||||
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
|
||||
configuration returns 503; an unreachable agent server or malformed upstream JSON
|
||||
returns 502.
|
||||
|
||||
### `GET /v1/agent/messages`
|
||||
|
||||
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
|
||||
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
|
||||
history. `after` catches up forward; `before` loads earlier history.
|
||||
|
||||
```json
|
||||
{
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
|
||||
"cursor": "43",
|
||||
"beforeCursor": "43",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Messages use `user`, `assistant`, and `tool` roles. Tools use
|
||||
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
|
||||
pages can update initiated tools to completed or failed. Internal worker reports,
|
||||
event prompts, and model reasoning are excluded. Cursors refer to underlying
|
||||
durable entries and can advance without visible messages. Use returned cursors;
|
||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||
messages.
|
||||
|
||||
### `POST /v1/agent/messages`
|
||||
|
||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||
Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included;
|
||||
`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. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted.
|
||||
The proxy verifies successful receipts contain a positive submission ID, a known
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt.
|
||||
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.
|
||||
|
||||
### Agent attachments (v1)
|
||||
|
||||
`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry:
|
||||
|
||||
```json
|
||||
{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}}
|
||||
```
|
||||
|
||||
`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged.
|
||||
|
||||
`POST /v1/agent/messages`:
|
||||
|
||||
```json
|
||||
{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]}
|
||||
```
|
||||
|
||||
`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt:
|
||||
|
||||
```json
|
||||
{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]}
|
||||
```
|
||||
|
||||
Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure.
|
||||
|
||||
History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged.
|
||||
|
||||
`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text.
|
||||
|
||||
### Attachment capability and errors
|
||||
|
||||
Optional `GET /v1/agent` field:
|
||||
|
||||
```json
|
||||
{"capabilities":{"attachments":{"version":1,"maxCount":8,"maxTotalBytes":33554432,"maxImageBytes":6291456,"maxFileBytes":8388608,"imageMimeTypes":["image/png","image/jpeg","image/gif","image/webp"],"maxImagePixels":50000000,"maxImageDimension":16384,"quotaBytes":1073741824,"usedBytes":0,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}}
|
||||
```
|
||||
|
||||
Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible.
|
||||
|
||||
Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads.
|
||||
|
||||
### Runtime access and release requirements
|
||||
|
||||
The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads/<id>/<safe-original-basename>` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute.
|
||||
|
||||
Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots.
|
||||
|
||||
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
|
||||
|
||||
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
|
||||
|
||||
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
|
||||
|
||||
+7
-142
@@ -1,30 +1,27 @@
|
||||
# Streaming Chat API Contract
|
||||
|
||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
|
||||
This document defines the server-sent events (SSE) contract for chat completions.
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/chat-completions/stream`
|
||||
- `POST /v1/chats/:chatId/stream/attach`
|
||||
- `POST /v1/quick-questions/stream`
|
||||
|
||||
Transport:
|
||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||
- Request body is JSON
|
||||
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
||||
|
||||
Authentication:
|
||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||
- The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged.
|
||||
|
||||
## Chat Completion Request Body
|
||||
## Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"clientRequestId": "optional-client-generated-id",
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
{
|
||||
@@ -64,21 +61,15 @@ Notes:
|
||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||
- If `chatId` is provided, backend validates it exists.
|
||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
|
||||
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
|
||||
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
|
||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||
|
||||
## Attach Endpoint
|
||||
@@ -92,32 +83,6 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
|
||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||
|
||||
## Quick Question Endpoint
|
||||
|
||||
`POST /v1/quick-questions/stream`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||
"model": "string",
|
||||
"question": "What is the capital of France?",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"userLocation": "optional city, region, country",
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
|
||||
- `question` is required, trimmed by the server, and must not be empty.
|
||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
||||
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
||||
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
||||
|
||||
## Event Stream Contract
|
||||
|
||||
Event order:
|
||||
@@ -208,21 +173,18 @@ Terminal tool-call event:
|
||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
|
||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||
- Stream may emit `tool_call` events while tool calls are executed.
|
||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
@@ -276,100 +238,3 @@ event: done
|
||||
data: {"type":"done","text":"Hello world"}
|
||||
|
||||
```
|
||||
|
||||
## Sybil Agent WebSocket
|
||||
|
||||
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
|
||||
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
|
||||
connected after each assistant turn and receives later background results. Sending
|
||||
messages uses `POST /v1/agent/messages`.
|
||||
|
||||
The browser must send this first frame within five seconds:
|
||||
|
||||
```json
|
||||
{ "type": "auth", "token": "Sybil token", "after": "43" }
|
||||
```
|
||||
|
||||
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
|
||||
the last successfully merged cursor. Invalid credentials close with 4401, malformed
|
||||
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
|
||||
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
|
||||
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
|
||||
browser credentials are not forwarded to the agent.
|
||||
|
||||
Native `pi-room.v1` frames are forwarded after authentication. This protocol
|
||||
identifier is retained for compatibility with existing clients:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "messages",
|
||||
"conversationId": "1",
|
||||
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
|
||||
"cursor": "44",
|
||||
"beforeCursor": "44",
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
|
||||
```
|
||||
|
||||
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
|
||||
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
|
||||
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
|
||||
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
|
||||
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
||||
applying its entire frame, even when it contains no visible messages.
|
||||
|
||||
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
||||
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
||||
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
||||
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
|
||||
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
|
||||
cursor. Missing agent configuration closes with 4503.
|
||||
|
||||
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
||||
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||
|
||||
### Agent attachment history, queued activity and replay
|
||||
|
||||
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
|
||||
|
||||
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "inbound:sybil:client-stable-id",
|
||||
"role": "user",
|
||||
"content": "Read these notes",
|
||||
"createdAt": "2026-10-14T18:00:00.000Z",
|
||||
"metadata": {
|
||||
"kind": "message",
|
||||
"channel": "sybil",
|
||||
"status": "queued",
|
||||
"agentAttachments": [{
|
||||
"id": "att_12345678-1234-4234-8234-123456789abc",
|
||||
"kind": "text",
|
||||
"filename": "notes.txt",
|
||||
"mimeType": "text/plain",
|
||||
"sizeBytes": 12,
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them.
|
||||
|
||||
Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption.
|
||||
|
||||
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
|
||||
|
||||
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
|
||||
|
||||
+24
-5
@@ -1,5 +1,24 @@
|
||||
ASC_KEY_ID=
|
||||
ASC_ISSUER_ID=
|
||||
ASC_KEY=
|
||||
MATCH_PASSWORD=
|
||||
MATCH_GIT_BASIC_AUTHORIZATION=
|
||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||
FASTLANE_HIDE_CHANGELOG=1
|
||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
||||
SYBIL_PROVISIONING_PROFILE_UUID=
|
||||
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
|
||||
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
|
||||
SYBIL_SIGNING_CERTIFICATE_ID=
|
||||
SYBIL_SIGNING_KEYCHAIN=
|
||||
|
||||
# App Store Connect API key settings for TestFlight upload and signing setup.
|
||||
APP_STORE_CONNECT_API_KEY_ID=
|
||||
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||
APP_STORE_CONNECT_API_KEY_PATH=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||
|
||||
# Optional deployment overrides.
|
||||
SYBIL_BUILD_NUMBER=
|
||||
SYBIL_VERSION_TAG=
|
||||
|
||||
@@ -21,12 +21,6 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||
|
||||
## Release Workflow
|
||||
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
|
||||
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
|
||||
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
|
||||
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
|
||||
|
||||
## App Structure
|
||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||
- Shared iOS app code lives in Swift package:
|
||||
@@ -57,5 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- OpenAI: `gpt-4.1-mini`
|
||||
- Anthropic: `claude-3-5-sonnet-latest`
|
||||
- xAI: `grok-3-mini`
|
||||
- Gemini: `gemini-3.5-flash`
|
||||
- Hermes Agent: `hermes-agent`
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -8,7 +8,7 @@ struct SybilApp: App
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
@@ -24,18 +24,9 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
@@ -65,22 +56,11 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if scene is UIWindowScene, session.role == .windowApplication {
|
||||
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
|
||||
@@ -11,13 +11,6 @@ targets:
|
||||
dependencies:
|
||||
- package: Sybil
|
||||
product: Sybil
|
||||
- target: SybilMacQuickQuestion
|
||||
destinationFilters: [macCatalyst]
|
||||
embed: true
|
||||
link: false
|
||||
codeSign: true
|
||||
copy:
|
||||
destination: plugins
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
@@ -31,12 +24,11 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: "1.13.2"
|
||||
MARKETING_VERSION: "1.10"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import AppKit
|
||||
import Carbon
|
||||
|
||||
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
||||
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
||||
@MainActor
|
||||
@objc(SybilMacQuickQuestionPlugin)
|
||||
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
private var panel: QuickQuestionPanel?
|
||||
private var panelView: QuickQuestionPanelView?
|
||||
private var hotKey: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
private var outsideClickMonitor: Any?
|
||||
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
private var isDismissing = false
|
||||
private var shortcutAvailable = false
|
||||
private var windowlessActivity: NSObjectProtocol?
|
||||
private var latestState = SybilQuickQuestionPanelState()
|
||||
private var previousApplication: NSRunningApplication?
|
||||
|
||||
required override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
var isVisible: Bool { panel?.isVisible == true }
|
||||
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||
self.delegate = delegate
|
||||
if windowlessActivity == nil {
|
||||
// Keep the hotkey service alive with no windows. This option does
|
||||
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
||||
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: .automaticTerminationDisabled,
|
||||
reason: "Quick Question global shortcut"
|
||||
)
|
||||
}
|
||||
guard eventHandler == nil else { return noErr }
|
||||
var eventTypes = [
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
||||
]
|
||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||
let handlerStatus = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ _, event, context in
|
||||
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
||||
var identifier = EventHotKeyID()
|
||||
let status = GetEventParameter(
|
||||
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
||||
)
|
||||
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
||||
return OSStatus(eventNotHandledErr)
|
||||
}
|
||||
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
||||
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
||||
// Application event handlers run on the main event loop.
|
||||
MainActor.assumeIsolated {
|
||||
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
||||
plugin.delegate?.quickQuestionToggleRequested()
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
eventTypes.count, &eventTypes, context, &eventHandler
|
||||
)
|
||||
guard handlerStatus == noErr else { return handlerStatus }
|
||||
let status = RegisterEventHotKey(
|
||||
UInt32(kVK_Space), UInt32(optionKey),
|
||||
EventHotKeyID(signature: 0x53595151, id: 1),
|
||||
GetApplicationEventTarget(), 0, &hotKey
|
||||
)
|
||||
shortcutAvailable = status == noErr
|
||||
return status
|
||||
}
|
||||
|
||||
func updateState(_ data: Data) {
|
||||
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
||||
// Updating a background request must not create a non-hosting window.
|
||||
// The controller creates/orders the panel only after UIKit is foreground.
|
||||
latestState = state
|
||||
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
||||
if isVisible { resizePanel() }
|
||||
}
|
||||
|
||||
func show() {
|
||||
createPanelIfNeeded()
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
||||
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
||||
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
||||
let width = min(680, visibleFrame.width - 48)
|
||||
panel.setContentSize(NSSize(width: width, height: 240))
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
||||
let y = max(visibleFrame.minY + 24, top - height)
|
||||
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
||||
// The controller has verified UIKit foreground readiness. A
|
||||
// nonactivating panel can then take focus without raising a workspace.
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
panel.makeFirstResponder(panelView.promptView)
|
||||
panelView.preparePromptForPresentation()
|
||||
installOutsideClickMonitor()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
dismissPanel(restoringPreviousApplication: true)
|
||||
}
|
||||
|
||||
func activateForQuickQuestion() {
|
||||
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
||||
frontmost != NSRunningApplication.current {
|
||||
previousApplication = frontmost
|
||||
}
|
||||
// Do not order a window here. Catalyst must finish its own foreground
|
||||
// transition before the controller calls show().
|
||||
NSApp.activate()
|
||||
}
|
||||
|
||||
func activateMainWindow() {
|
||||
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
||||
previousApplication = nil
|
||||
NSApp.activate()
|
||||
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func dismissPanel(restoringPreviousApplication: Bool) {
|
||||
guard !isDismissing else { return }
|
||||
let wasVisible = isVisible
|
||||
isDismissing = true
|
||||
if wasVisible { panel?.orderOut(nil) }
|
||||
removeOutsideClickMonitor()
|
||||
isDismissing = false
|
||||
let applicationToRestore = previousApplication
|
||||
previousApplication = nil
|
||||
if restoringPreviousApplication, NSApp.isActive,
|
||||
let applicationToRestore, !applicationToRestore.isTerminated {
|
||||
NSApp.yieldActivation(to: applicationToRestore)
|
||||
applicationToRestore.activate(options: [])
|
||||
}
|
||||
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
previousApplication = nil
|
||||
hide()
|
||||
removeOutsideClickMonitor()
|
||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||
if let eventHandler { RemoveEventHandler(eventHandler) }
|
||||
hotKey = nil
|
||||
eventHandler = nil
|
||||
hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
if let windowlessActivity {
|
||||
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
||||
}
|
||||
windowlessActivity = nil
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
// Focus is already moving to a window/app the user chose.
|
||||
dismissPanel(restoringPreviousApplication: false)
|
||||
}
|
||||
|
||||
private func createPanelIfNeeded() {
|
||||
guard panel == nil else { return }
|
||||
let panel = QuickQuestionPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
||||
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
||||
)
|
||||
panel.title = "Quick Question"
|
||||
panel.level = .floating
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||
panel.isFloatingPanel = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
panel.worksWhenModal = true
|
||||
panel.isReleasedWhenClosed = false
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.appearance = NSAppearance(named: .darkAqua)
|
||||
panel.delegate = self
|
||||
panel.onDismiss = { [weak self] in self?.hide() }
|
||||
let view = QuickQuestionPanelView()
|
||||
view.delegate = delegate
|
||||
view.onDismiss = { [weak self] in self?.hide() }
|
||||
panel.contentView = view
|
||||
panelView = view
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func resizePanel() {
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
guard abs(height - panel.frame.height) > 1 else { return }
|
||||
var frame = panel.frame
|
||||
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
||||
frame.size.height = height
|
||||
panel.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
private func installOutsideClickMonitor() {
|
||||
guard outsideClickMonitor == nil else { return }
|
||||
// Mouse-only monitoring does not require Accessibility or Input
|
||||
// Monitoring permission. No global keyboard event tap is installed.
|
||||
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.hide() }
|
||||
}
|
||||
}
|
||||
|
||||
private func removeOutsideClickMonitor() {
|
||||
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
||||
outsideClickMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class QuickQuestionPanel: NSPanel {
|
||||
var onDismiss: (() -> Void)?
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
|
||||
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
let promptView = QuickQuestionPromptView()
|
||||
private let stack = NSStackView()
|
||||
private let answerView = NSTextView()
|
||||
private let answerScroll = NSScrollView()
|
||||
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
||||
private let providerPopup = NSPopUpButton()
|
||||
private let modelPopup = NSPopUpButton()
|
||||
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
|
||||
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
|
||||
private let sendButton = NSButton()
|
||||
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
|
||||
private var panelState = SybilQuickQuestionPanelState()
|
||||
|
||||
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
material = .hudWindow
|
||||
blendingMode = .behindWindow
|
||||
state = .active
|
||||
wantsLayer = true
|
||||
layer?.cornerRadius = 18
|
||||
layer?.masksToBounds = true
|
||||
layer?.borderWidth = 1
|
||||
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
|
||||
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.detachesHiddenViews = true
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
|
||||
])
|
||||
|
||||
addRow(makeHeader())
|
||||
configureTextView(promptView, fontSize: 22)
|
||||
promptView.delegate = self
|
||||
promptView.isRichText = false
|
||||
promptView.allowsUndo = true
|
||||
promptView.setAccessibilityLabel("Quick question prompt")
|
||||
promptView.onSubmit = { [weak self] in self?.submit() }
|
||||
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
|
||||
let promptScroll = makeScrollView(documentView: promptView)
|
||||
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
|
||||
addRow(promptScroll)
|
||||
|
||||
configureTextView(answerView, fontSize: 15)
|
||||
answerView.isEditable = false
|
||||
answerView.isSelectable = true
|
||||
answerView.setAccessibilityLabel("Quick question answer")
|
||||
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
|
||||
answerScroll.drawsBackground = false
|
||||
answerScroll.hasVerticalScroller = true
|
||||
answerScroll.autohidesScrollers = true
|
||||
answerScroll.documentView = answerView
|
||||
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
|
||||
addRow(answerScroll)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.maximumNumberOfLines = 3
|
||||
addRow(statusLabel)
|
||||
addRow(makeControls())
|
||||
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
|
||||
hint.font = .systemFont(ofSize: 11)
|
||||
hint.textColor = .secondaryLabelColor
|
||||
addRow(hint)
|
||||
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
|
||||
let previous = panelState
|
||||
panelState = next
|
||||
if promptView.string != next.prompt, !promptView.hasMarkedText() {
|
||||
promptView.string = next.prompt
|
||||
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
|
||||
}
|
||||
promptView.isEditable = !next.isBusy
|
||||
promptView.needsDisplay = true
|
||||
if previous.answer != next.answer {
|
||||
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
|
||||
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
|
||||
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
|
||||
}
|
||||
answerScroll.isHidden = next.answer.isEmpty
|
||||
|
||||
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
|
||||
providerPopup.removeAllItems()
|
||||
for provider in next.providers {
|
||||
providerPopup.addItem(withTitle: provider.title)
|
||||
providerPopup.lastItem?.representedObject = provider.id
|
||||
}
|
||||
}
|
||||
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
|
||||
providerPopup.selectItem(at: index)
|
||||
}
|
||||
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
|
||||
modelPopup.removeAllItems()
|
||||
var models = next.models
|
||||
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
|
||||
modelPopup.addItems(withTitles: models)
|
||||
modelPopup.selectItem(withTitle: next.model)
|
||||
}
|
||||
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||
convertButton.isEnabled = next.canConvert
|
||||
sendButton.isEnabled = next.canSend
|
||||
openAppButton.isHidden = next.isAuthenticated
|
||||
convertButton.isHidden = !next.isAuthenticated
|
||||
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
|
||||
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
|
||||
|
||||
let status: String
|
||||
if let error = next.error {
|
||||
status = error
|
||||
statusLabel.textColor = .systemRed
|
||||
} else {
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
if next.isCheckingSession {
|
||||
status = "Connecting to Sybil…"
|
||||
} else if !next.isAuthenticated {
|
||||
status = "Connect in Sybil before asking a question."
|
||||
} else if next.isConverting {
|
||||
status = "Opening in chat…"
|
||||
} else if next.isSending {
|
||||
status = next.toolSummaries.last ?? "Thinking…"
|
||||
} else {
|
||||
status = ""
|
||||
}
|
||||
}
|
||||
statusLabel.stringValue = status
|
||||
statusLabel.isHidden = status.isEmpty
|
||||
}
|
||||
|
||||
func preparePromptForPresentation() {
|
||||
// Select only when invoked, never on streamed updates or normal edits.
|
||||
promptView.selectAll(nil)
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !panelState.isBusy else { return }
|
||||
promptView.needsDisplay = true
|
||||
delegate?.quickQuestionPromptChanged(promptView.string)
|
||||
}
|
||||
|
||||
private func addRow(_ view: NSView) {
|
||||
stack.addArrangedSubview(view)
|
||||
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
|
||||
private func makeHeader() -> NSView {
|
||||
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
|
||||
icon.contentTintColor = .systemPurple
|
||||
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
||||
let title = NSTextField(labelWithString: "Quick Question")
|
||||
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
|
||||
shortcutLabel.textColor = .secondaryLabelColor
|
||||
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
|
||||
close.isBordered = false
|
||||
close.toolTip = "Close (Esc)"
|
||||
close.setAccessibilityLabel("Close Quick Question")
|
||||
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
|
||||
row.spacing = 8
|
||||
row.alignment = .centerY
|
||||
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeControls() -> NSView {
|
||||
providerPopup.target = self
|
||||
providerPopup.action = #selector(changeProvider)
|
||||
providerPopup.setAccessibilityLabel("Quick question provider")
|
||||
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||
modelPopup.target = self
|
||||
modelPopup.action = #selector(changeModel)
|
||||
modelPopup.setAccessibilityLabel("Quick question model")
|
||||
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
|
||||
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
convertButton.target = self
|
||||
convertButton.action = #selector(convertToChat)
|
||||
convertButton.bezelStyle = .rounded
|
||||
openAppButton.target = self
|
||||
openAppButton.action = #selector(openApp)
|
||||
openAppButton.bezelStyle = .rounded
|
||||
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
|
||||
sendButton.imagePosition = .imageOnly
|
||||
sendButton.bezelStyle = .circular
|
||||
sendButton.contentTintColor = .systemPurple
|
||||
sendButton.target = self
|
||||
sendButton.action = #selector(submit)
|
||||
sendButton.setAccessibilityLabel("Ask Quick Question")
|
||||
sendButton.toolTip = "Ask (Return)"
|
||||
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
||||
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
|
||||
row.spacing = 8
|
||||
row.alignment = .centerY
|
||||
row.detachesHiddenViews = true
|
||||
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
||||
return row
|
||||
}
|
||||
|
||||
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
|
||||
view.drawsBackground = false
|
||||
view.textColor = .labelColor
|
||||
view.insertionPointColor = .labelColor
|
||||
view.font = .systemFont(ofSize: fontSize)
|
||||
view.textContainerInset = NSSize(width: 0, height: 4)
|
||||
view.isHorizontallyResizable = false
|
||||
view.isVerticallyResizable = true
|
||||
view.autoresizingMask = [.width]
|
||||
view.textContainer?.widthTracksTextView = true
|
||||
view.textContainer?.lineFragmentPadding = 0
|
||||
}
|
||||
|
||||
private func makeScrollView(documentView: NSView) -> NSScrollView {
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.documentView = documentView
|
||||
return scroll
|
||||
}
|
||||
|
||||
private static func renderAnswer(_ text: String) -> NSAttributedString {
|
||||
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 4
|
||||
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
|
||||
for run in parsed.runs {
|
||||
let intent = run.inlinePresentationIntent ?? []
|
||||
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
|
||||
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
|
||||
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
|
||||
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
@objc private func closePanel() { onDismiss?() }
|
||||
@objc private func changeProvider() {
|
||||
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
|
||||
delegate?.quickQuestionProviderChanged(provider)
|
||||
}
|
||||
@objc private func changeModel() {
|
||||
guard let model = modelPopup.titleOfSelectedItem else { return }
|
||||
delegate?.quickQuestionModelChanged(model)
|
||||
}
|
||||
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
|
||||
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
|
||||
@objc private func submit() {
|
||||
guard panelState.canSend, !promptView.hasMarkedText() else { return }
|
||||
delegate?.quickQuestionSubmitRequested()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class QuickQuestionPromptView: NSTextView {
|
||||
var onSubmit: (() -> Void)?
|
||||
var onToggle: (() -> Void)?
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
super.draw(dirtyRect)
|
||||
guard string.isEmpty else { return }
|
||||
("Ask anything…" as NSString).draw(
|
||||
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
|
||||
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
|
||||
)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
if event.keyCode == 49, modifiers == .option {
|
||||
if !event.isARepeat { onToggle?() }
|
||||
return
|
||||
}
|
||||
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
|
||||
if !event.isARepeat { onSubmit?() }
|
||||
return
|
||||
}
|
||||
// Let the text input system handle Shift+Return and IME confirmation.
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
targets:
|
||||
SybilMacQuickQuestion:
|
||||
type: bundle
|
||||
platform: macOS
|
||||
deploymentTarget: "15.0"
|
||||
sources:
|
||||
- Sources
|
||||
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
|
||||
dependencies:
|
||||
- sdk: AppKit.framework
|
||||
- sdk: Carbon.framework
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
|
||||
PRODUCT_NAME: SybilMacQuickQuestion
|
||||
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
|
||||
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
|
||||
MACH_O_TYPE: mh_bundle
|
||||
SKIP_INSTALL: YES
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "2.237.0"
|
||||
gem "fastlane", "~> 2.227"
|
||||
|
||||
+62
-75
@@ -1,49 +1,46 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
CFPropertyList (3.0.9)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1274.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.228.1)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (1.6.0)
|
||||
logger
|
||||
excon (0.109.0)
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -73,45 +70,42 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
fastlane (2.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@@ -126,46 +120,41 @@ GEM
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.65.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
@@ -173,24 +162,22 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
json (2.7.6)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -202,10 +189,11 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
@@ -218,16 +206,15 @@ GEM
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
@@ -238,7 +225,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.237.0)
|
||||
fastlane (~> 2.227)
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
|
||||
@@ -24,8 +24,7 @@ let package = Package(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "SybilTests",
|
||||
dependencies: ["Sybil"]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "character-still.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
@@ -78,7 +78,6 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
@@ -86,11 +85,7 @@ public struct SplitView: View {
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
@@ -106,18 +101,12 @@ public struct SplitView: View {
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: isQuickQuestionPresented) { _, presented in
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
@@ -162,13 +151,8 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
SybilMacQuickQuestionController.shared.show()
|
||||
#else
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
@@ -177,57 +161,23 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
public static let workspaceWindowID = "sybil-workspace"
|
||||
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
windowCommands.newChatOrWindow(
|
||||
newChat: keyboardActions?.newChat,
|
||||
openWindow: openNewWindow
|
||||
)
|
||||
#else
|
||||
keyboardActions?.newChat()
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||
#else
|
||||
.disabled(keyboardActions == nil)
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button("New Window", action: openNewWindow)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||
#else
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Divider()
|
||||
Button("Quick Question") {
|
||||
SybilMacQuickQuestionController.shared.toggle()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: .option)
|
||||
#endif
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
@@ -244,13 +194,6 @@ public struct SybilCommands: Commands {
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func openNewWindow() {
|
||||
// Targeting a WindowGroup without a value creates a fresh scene every
|
||||
// time, even when another workspace window is already open.
|
||||
openWindow(id: Self.workspaceWindowID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActions {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct APIConfiguration: Sendable {
|
||||
var baseURL: URL
|
||||
@@ -18,7 +17,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -41,115 +40,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
var 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.")
|
||||
}
|
||||
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
|
||||
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 agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
if !submission.attachmentIDs.isEmpty {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
|
||||
}
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
|
||||
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
|
||||
request.timeoutInterval = 120
|
||||
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = multipart.body
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
|
||||
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
struct UploadResponse: Decodable { var attachment: AgentAttachment }
|
||||
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
|
||||
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
let attachment = response.attachment
|
||||
try attachment.validate()
|
||||
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
|
||||
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
|
||||
}
|
||||
return attachment
|
||||
}
|
||||
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
|
||||
try attachment.validate()
|
||||
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
|
||||
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
|
||||
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
guard data.count == attachment.sizeBytes,
|
||||
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
|
||||
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
|
||||
defer { stream.task.cancel() }
|
||||
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
let success = (200...299).contains(response.statusCode)
|
||||
let limit = success ? maximum : 64 * 1024
|
||||
guard response.expectedContentLength <= Int64(limit) else {
|
||||
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
|
||||
}
|
||||
var data = Data()
|
||||
for try await byte in stream {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
task.resume()
|
||||
let socket = AgentURLSocket(task: task)
|
||||
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
|
||||
catch { await socket.close(); throw error }
|
||||
return socket
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||
}
|
||||
@@ -159,15 +49,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return response.items
|
||||
}
|
||||
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
|
||||
try await request(
|
||||
"/v1/workspace-items",
|
||||
method: "GET",
|
||||
queryItems: [URLQueryItem(name: "q", value: query)],
|
||||
responseType: WorkspaceSearchResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
@@ -193,16 +74,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/fork",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
@@ -312,29 +183,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||
}
|
||||
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
let request = try makeRequest(
|
||||
path: "/v1/quick-questions/stream",
|
||||
method: "POST",
|
||||
body: AnyEncodable(body),
|
||||
acceptsSSE: true
|
||||
)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.network,
|
||||
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||
)
|
||||
|
||||
try await stream(request: request) { eventName, dataText in
|
||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
||||
}
|
||||
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
@@ -409,10 +257,9 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: AnyEncodable? = nil,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
|
||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||
|
||||
SybilLog.debug(
|
||||
SybilLog.network,
|
||||
@@ -423,7 +270,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||
SybilLog.debug(
|
||||
@@ -437,10 +284,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
throw wrapped
|
||||
}
|
||||
|
||||
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
|
||||
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
try validate(response: response, data: data)
|
||||
|
||||
do {
|
||||
@@ -468,14 +311,12 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
path: String,
|
||||
method: String,
|
||||
body: AnyEncodable?,
|
||||
acceptsSSE: Bool,
|
||||
queryItems: [URLQueryItem] = []
|
||||
acceptsSSE: Bool
|
||||
) throws -> URLRequest {
|
||||
let url = try buildURL(path: path, queryItems: queryItems)
|
||||
let url = try buildURL(path: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
|
||||
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
|
||||
request.timeoutInterval = 120
|
||||
|
||||
if acceptsSSE {
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
@@ -495,7 +336,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return request
|
||||
}
|
||||
|
||||
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
|
||||
private func buildURL(path: String) throws -> URL {
|
||||
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidBaseURL
|
||||
}
|
||||
@@ -506,9 +347,6 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
basePath.removeLast()
|
||||
}
|
||||
components.path = "\(basePath)/\(trimmedPath)"
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = (components.queryItems ?? []) + queryItems
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidBaseURL
|
||||
@@ -826,12 +664,6 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var question: String
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
var provider: Provider?
|
||||
@@ -843,10 +675,6 @@ private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct ChatForkBody: Encodable {
|
||||
var messageId: String?
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Foundation
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(
|
||||
title: String?,
|
||||
@@ -12,7 +11,6 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
@@ -29,10 +27,6 @@ protocol SybilAPIClienting: Sendable {
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws
|
||||
func attachCompletionStream(
|
||||
chatID: String,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
|
||||
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
|
||||
actor AgentAttachmentDownloads {
|
||||
private var active = 0
|
||||
private var invalidated = false
|
||||
private var transfers: [UUID: Task<Data, any Error>] = [:]
|
||||
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
|
||||
private var cache: [AgentAttachment: Data] = [:]
|
||||
private var order: [AgentAttachment] = []
|
||||
private var byteCount = 0
|
||||
|
||||
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if let value = cache[attachment] { return value }
|
||||
try await acquire()
|
||||
defer { release() }
|
||||
try Task.checkCancellation()
|
||||
if let value = cache[attachment] { return value }
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
let id = UUID()
|
||||
let transfer = Task { try await client.agentDownload(attachment) }
|
||||
transfers[id] = transfer
|
||||
defer { transfers[id] = nil }
|
||||
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
|
||||
let oldest = order.removeFirst()
|
||||
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
|
||||
}
|
||||
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
|
||||
return value
|
||||
}
|
||||
func invalidate() {
|
||||
invalidated = true
|
||||
transfers.values.forEach { $0.cancel() }
|
||||
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
|
||||
waiters = []; cache = [:]; order = []; byteCount = 0
|
||||
}
|
||||
private func acquire() async throws {
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if active < 2 { active += 1; return }
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
|
||||
else { waiters.append((id, continuation)) }
|
||||
}
|
||||
} onCancel: { Task { await self.cancel(id) } }
|
||||
}
|
||||
private func cancel(_ id: UUID) {
|
||||
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
|
||||
waiters.remove(at: index).1.resume(throwing: CancellationError())
|
||||
}
|
||||
private func release() {
|
||||
if waiters.isEmpty { active -= 1 }
|
||||
else { waiters.removeFirst().1.resume() }
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
|
||||
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
|
||||
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
|
||||
var filename: String
|
||||
var mimeType: String?
|
||||
var sizeBytes: Int?
|
||||
var state: State = .loading
|
||||
var attachment: AgentAttachment?
|
||||
var issue: AgentAttachmentIssue?
|
||||
}
|
||||
struct AgentDraftSnapshot: Codable, Sendable {
|
||||
var caption = ""
|
||||
var items: [AgentDraftItem] = []
|
||||
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
|
||||
}
|
||||
struct AgentDraftStorage: Sendable {
|
||||
var read: @Sendable () throws -> AgentDraftSnapshot
|
||||
var write: @Sendable (AgentDraftSnapshot) throws -> Void
|
||||
var stage: @Sendable (String, Data) throws -> Void
|
||||
var bytes: @Sendable (String) throws -> Data
|
||||
var remove: @Sendable (String) -> Void
|
||||
|
||||
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
|
||||
let manifest = directory.appendingPathComponent("draft.json")
|
||||
let file: @Sendable (String) throws -> URL = { id in
|
||||
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
|
||||
return directory.appendingPathComponent(id + ".bytes")
|
||||
}
|
||||
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
}
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
|
||||
let data = try Data(contentsOf: manifest)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
|
||||
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
|
||||
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
|
||||
return snapshot
|
||||
}, write: { snapshot in
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
try write(data, manifest)
|
||||
}, stage: { id, data in
|
||||
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
try write(data, file(id))
|
||||
}, bytes: { id in
|
||||
let url = try file(id)
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
return try Data(contentsOf: url)
|
||||
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Observable
|
||||
final class AgentAttachmentDraft {
|
||||
private(set) var id = UUID().uuidString
|
||||
var caption = "" { didSet { save() } }
|
||||
private(set) var items: [AgentDraftItem] = []
|
||||
private(set) var capability: AgentAttachmentCapabilities?
|
||||
private(set) var error: String?
|
||||
private(set) var persistenceAvailable = true
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentDraftStorage?
|
||||
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var worker: Task<Void, Never>?
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var restoring = false
|
||||
@ObservationIgnored private var active = true
|
||||
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
|
||||
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
|
||||
var canSend: Bool {
|
||||
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
|
||||
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
|
||||
&& persistenceAvailable
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
|
||||
suspend()
|
||||
self.client = client; self.storage = storage
|
||||
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
|
||||
restoring = true
|
||||
defer { restoring = false; active = true }
|
||||
do {
|
||||
let saved = try storage?.read() ?? AgentDraftSnapshot()
|
||||
id = saved.id ?? UUID().uuidString
|
||||
caption = saved.caption
|
||||
items = saved.items.map { item in
|
||||
var item = item
|
||||
if item.state != .ready {
|
||||
item.state = .failed
|
||||
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
|
||||
}
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
caption = ""; items = []; persistenceAvailable = false
|
||||
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
func updateCapability(_ value: AgentAttachmentCapabilities?) {
|
||||
capability = value?.supported == true ? value : nil
|
||||
if capability == nil { suspend(); active = true }
|
||||
}
|
||||
func resume() { active = true; pump() }
|
||||
func suspend() {
|
||||
active = false; generation = UUID(); worker?.cancel(); worker = nil
|
||||
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
|
||||
items[index].state = .failed
|
||||
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
|
||||
}
|
||||
save()
|
||||
}
|
||||
@discardableResult
|
||||
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
|
||||
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
|
||||
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
|
||||
let item = AgentDraftItem(filename: filename)
|
||||
items.append(item); loaders[item.id] = load; error = nil
|
||||
save(); pump()
|
||||
return item.id
|
||||
}
|
||||
func remove(_ id: String) {
|
||||
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
|
||||
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
|
||||
save()
|
||||
if persistenceAvailable { storage?.remove(id) }
|
||||
for other in interrupted where other != id {
|
||||
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
|
||||
}
|
||||
pump()
|
||||
}
|
||||
func retry(_ id: String) {
|
||||
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
|
||||
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
|
||||
items[index].state = .loading; items[index].issue = nil; error = nil
|
||||
save(); pump()
|
||||
}
|
||||
func validateReady() throws -> [AgentAttachment] {
|
||||
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
|
||||
if let capability { try capability.validate(ready) }
|
||||
return ready
|
||||
}
|
||||
@discardableResult func clearAfterSubmission() -> Bool {
|
||||
guard persistenceAvailable else { return false }
|
||||
let nextID = UUID().uuidString
|
||||
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
|
||||
catch {
|
||||
persistenceAvailable = false
|
||||
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
let ids = items.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
restoring = true; caption = ""; items = []; id = nextID; restoring = false
|
||||
prepared = [:]; loaders = [:]
|
||||
ids.forEach { storage?.remove($0) }
|
||||
return true
|
||||
}
|
||||
func save() {
|
||||
guard !restoring, persistenceAvailable else { return }
|
||||
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
|
||||
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
|
||||
}
|
||||
private func pump() {
|
||||
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
|
||||
let item = items.first(where: { $0.state == .loading }) else { return }
|
||||
let run = generation
|
||||
worker = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.worker = nil; self.pump() } }
|
||||
do {
|
||||
let file: AgentPreparedAttachment
|
||||
if let existing = self.prepared[item.id] { file = existing }
|
||||
else if let mime = item.mimeType, let storage = self.storage {
|
||||
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
|
||||
} else if let loader = self.loaders[item.id] { file = try await loader() }
|
||||
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
|
||||
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
|
||||
self.prepared[item.id] = file
|
||||
try self.storage?.stage(item.id, file.data)
|
||||
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
|
||||
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
|
||||
self.save()
|
||||
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
|
||||
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
|
||||
try attachment.validate()
|
||||
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
|
||||
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
|
||||
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
|
||||
self.save()
|
||||
self.loaders[item.id] = nil; self.prepared[item.id] = nil
|
||||
// Keep staged bytes until the message is durably handed to the outbox.
|
||||
} catch {
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
|
||||
// PhotosPicker does not expose the original PHAsset filename without broader library access.
|
||||
struct AgentPickedPhoto: Transferable, Sendable {
|
||||
var data: Data
|
||||
var filename: String?
|
||||
var mimeType: String?
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(importedContentType: .image) { received in
|
||||
let url = received.file
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
|
||||
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentComposer: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
@State private var modalID = UUID()
|
||||
@State private var pickerSession: UUID?
|
||||
@State private var showChoices = false
|
||||
@State private var showFiles = false
|
||||
@State private var showPhotos = false
|
||||
@State private var photos: [PhotosPickerItem] = []
|
||||
private var modal: Bool { showChoices || showFiles || showPhotos }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !room.attachmentDraft.items.isEmpty {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(room.attachmentDraft.items) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.filename).lineLimit(1)
|
||||
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
|
||||
.accessibilityLabel("Remove \(item.filename)")
|
||||
}
|
||||
if let attachment = item.attachment {
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
} else {
|
||||
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
|
||||
}
|
||||
if let issue = item.issue {
|
||||
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
|
||||
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
|
||||
.accessibilityIdentifier("agent-upload-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 190)
|
||||
}
|
||||
if let error = room.attachmentDraft.error {
|
||||
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if room.attachmentDraft.supportsAttachments {
|
||||
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
|
||||
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
|
||||
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
||||
Button("Photo Library") { showPhotos = true }
|
||||
Button("Files") { showFiles = true }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(isPresented: $showPhotos, selection: $photos,
|
||||
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
|
||||
selectionBehavior: .ordered, matching: .images)
|
||||
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
for url in urls {
|
||||
room.attachmentDraft.add(filename: url.lastPathComponent) {
|
||||
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
if (error as NSError).code != NSUserCancelledError {
|
||||
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: photos) { _, selected in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
|
||||
for item in selected {
|
||||
room.attachmentDraft.add(filename: "Photo") {
|
||||
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
|
||||
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
|
||||
}
|
||||
return try await AgentAttachmentPreparation.offMain {
|
||||
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
|
||||
}
|
||||
}
|
||||
}
|
||||
photos = []
|
||||
}
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
|
||||
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentDetails: View {
|
||||
var attachment: AgentAttachment
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if let path = attachment.workspacePath {
|
||||
Button {
|
||||
UIPasteboard.general.string = path
|
||||
} label: {
|
||||
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
|
||||
}
|
||||
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
|
||||
}
|
||||
}.font(.sybil(.caption2))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentCard: View {
|
||||
var attachment: AgentAttachment
|
||||
var room: SybilAgentRoom
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var image: UIImage?
|
||||
@State private var text: String?
|
||||
@State private var issue: String?
|
||||
@State private var loading = false
|
||||
@State private var wantsDownload = false
|
||||
@State private var revision = 0
|
||||
@State private var exportURL: URL?
|
||||
@State private var showShare = false
|
||||
@State private var modalID = UUID()
|
||||
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
if attachment.kind == .image {
|
||||
ZStack {
|
||||
SybilTheme.surface
|
||||
if let image { Image(uiImage: image).resizable().scaledToFit() }
|
||||
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
|
||||
}.frame(height: 180).clipped()
|
||||
}
|
||||
if let text {
|
||||
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
|
||||
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
|
||||
}
|
||||
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
|
||||
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
|
||||
HStack {
|
||||
if exportURL != nil {
|
||||
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
|
||||
} else if !loading {
|
||||
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
|
||||
.accessibilityIdentifier("agent-download")
|
||||
}
|
||||
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
|
||||
}.font(.sybil(.caption))
|
||||
}
|
||||
.padding(12).frame(maxWidth: 340, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
.task(id: loadKey) {
|
||||
// Each view owns its transfer; configuration and foreground changes cancel it.
|
||||
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
|
||||
loading = true; issue = nil
|
||||
let session = room.attachmentSessionID
|
||||
do {
|
||||
let data = try await room.downloadAttachment(attachment)
|
||||
try Task.checkCancellation()
|
||||
guard session == room.attachmentSessionID else { return }
|
||||
if attachment.kind == .image {
|
||||
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
|
||||
try Task.checkCancellation()
|
||||
image = UIImage(cgImage: bitmap)
|
||||
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
|
||||
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
|
||||
}
|
||||
if wantsDownload {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
exportURL = destination
|
||||
}
|
||||
loading = false
|
||||
} catch {
|
||||
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShare) {
|
||||
if let exportURL { AgentOriginalShare(url: exportURL) }
|
||||
}
|
||||
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
|
||||
}
|
||||
private func reset() {
|
||||
image = nil; text = nil
|
||||
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
|
||||
exportURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentOriginalShare: UIViewControllerRepresentable {
|
||||
var url: URL
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
|
||||
var version: Int
|
||||
var maxCount: Int
|
||||
var maxTotalBytes: Int
|
||||
var maxImageBytes: Int
|
||||
var maxFileBytes: Int
|
||||
var imageMimeTypes: [String]
|
||||
var maxImagePixels: Int
|
||||
var maxImageDimension: Int
|
||||
var quotaBytes: Int64? = nil
|
||||
var usedBytes: Int64? = nil
|
||||
var remainingBytes: Int64? = nil
|
||||
var maxConcurrentUploads: Int? = nil
|
||||
var maxConcurrentDecodes: Int? = nil
|
||||
|
||||
var supported: Bool {
|
||||
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
|
||||
&& maxImagePixels > 0 && maxImageDimension > 0
|
||||
}
|
||||
var countLimit: Int { min(maxCount, 8) }
|
||||
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
|
||||
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
|
||||
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
|
||||
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
|
||||
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
|
||||
func validate(_ attachments: [AgentAttachment]) throws {
|
||||
guard supported else { throw AgentAttachmentIssue.unavailable }
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
for attachment in attachments {
|
||||
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
|
||||
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
|
||||
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentCapabilities: Decodable, Sendable {
|
||||
var attachments: AgentAttachmentCapabilities?
|
||||
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
|
||||
private enum CodingKeys: String, CodingKey { case attachments }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Unknown versions or malformed future capabilities must not break text chat.
|
||||
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
enum Kind: String, Codable, Sendable { case image, text, file }
|
||||
var id: String
|
||||
var kind: Kind
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var sizeBytes: Int
|
||||
var sha256: String
|
||||
var workspacePath: String? = nil
|
||||
|
||||
func validate() throws {
|
||||
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
|
||||
try AgentAttachmentPreparation.filename(filename) == filename,
|
||||
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
|
||||
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
|
||||
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
|
||||
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
if let workspacePath {
|
||||
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
var metadataValue: JSONValue {
|
||||
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentAttachments: [AgentAttachment] {
|
||||
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
|
||||
return entries.compactMap {
|
||||
guard let data = try? JSONEncoder().encode($0),
|
||||
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
|
||||
(try? attachment.validate()) != nil else { return nil }
|
||||
return attachment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentSubmission: Encodable, Sendable {
|
||||
struct Reference: Encodable, Sendable { let id: String }
|
||||
let requestId: String
|
||||
let content: String
|
||||
let attachments: [Reference]?
|
||||
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
|
||||
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
|
||||
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
|
||||
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
|
||||
}
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
self.content = content; requestId = requestID
|
||||
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
|
||||
}
|
||||
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
|
||||
}
|
||||
|
||||
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
|
||||
var message: String
|
||||
var code: String
|
||||
var statusCode: Int?
|
||||
// Preserve additive quota information without inventing field names.
|
||||
var details: [String: JSONValue] = [:]
|
||||
var retryAfter: String? = nil
|
||||
var errorDescription: String? {
|
||||
var result = "\(message) [\(code)]"
|
||||
if code == "attachment_quota_exceeded" {
|
||||
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
|
||||
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
|
||||
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
|
||||
}
|
||||
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
|
||||
}
|
||||
if let retryAfter { result += " Retry-After: \(retryAfter)." }
|
||||
return result
|
||||
}
|
||||
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
|
||||
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
|
||||
static func wrapping(_ error: any Error) -> Self {
|
||||
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
|
||||
}
|
||||
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
|
||||
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
|
||||
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
|
||||
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPreparedAttachment: Sendable {
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var data: Data
|
||||
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
|
||||
enum AgentAttachmentPreparation {
|
||||
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
|
||||
let task = Task.detached(operation: operation)
|
||||
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
|
||||
}
|
||||
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
static func filename(_ value: String) throws -> String {
|
||||
let value = value.precomposedStringWithCanonicalMapping
|
||||
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
|
||||
!value.contains("/"), !value.contains("\\"),
|
||||
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func mime(_ value: String) throws -> String {
|
||||
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
|
||||
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
|
||||
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let accessed = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
|
||||
let name = try filename(url.lastPathComponent)
|
||||
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
return try prepare(data, filename: name, mimeType: media, limits: limits)
|
||||
}
|
||||
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let name = try filename(name), media = try mime(mimeType)
|
||||
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
|
||||
if nativeMIMEs.contains(media) {
|
||||
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
|
||||
}
|
||||
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
|
||||
}
|
||||
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
|
||||
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
|
||||
CGImageSourceGetStatus(source) == .statusComplete,
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
|
||||
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
|
||||
}
|
||||
return image
|
||||
}
|
||||
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let media = mimeType ?? "application/octet-stream"
|
||||
if nativeMIMEs.contains(media) {
|
||||
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
|
||||
}
|
||||
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
|
||||
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
|
||||
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
|
||||
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
|
||||
}
|
||||
let safe = try filename(originalName ?? "photo.heic")
|
||||
let stem = (safe as NSString).deletingPathExtension
|
||||
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentMultipart: Sendable {
|
||||
let boundary: String
|
||||
let body: Data
|
||||
init(file: AgentPreparedAttachment, uploadID: String) throws {
|
||||
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
|
||||
let name = try AgentAttachmentPreparation.filename(file.filename)
|
||||
let media = try AgentAttachmentPreparation.mime(file.mimeType)
|
||||
var boundary: String
|
||||
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
|
||||
self.boundary = boundary
|
||||
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
|
||||
bytes.append(file.data)
|
||||
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
|
||||
body = bytes
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct AgentFailure: LocalizedError, Sendable {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
// Compare decimal strings without losing precision through Double or overflowing Int.
|
||||
enum AgentCursor {
|
||||
static func normalize(_ value: String) throws -> String {
|
||||
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
|
||||
throw AgentFailure(message: "Invalid Agent cursor.")
|
||||
}
|
||||
let trimmed = value.drop(while: { $0 == "0" })
|
||||
return trimmed.isEmpty ? "0" : String(trimmed)
|
||||
}
|
||||
static func less(_ a: String, _ b: String) -> Bool {
|
||||
a.count == b.count ? a < b : a.count < b.count
|
||||
}
|
||||
static func advance(_ current: String, _ incoming: String) throws -> String {
|
||||
let value = try normalize(incoming)
|
||||
return less(current, value) ? value : current
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentActivity: Codable, Equatable, Sendable {
|
||||
struct Tool: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var name: String
|
||||
var status: String
|
||||
var output: String?
|
||||
}
|
||||
struct Wait: Codable, Equatable, Sendable {
|
||||
var due: Double
|
||||
var reason: String?
|
||||
}
|
||||
var state: String = "idle"
|
||||
var isThinking = false
|
||||
var queued = 0
|
||||
var backgroundWorkers = 0
|
||||
var tools: [Tool] = []
|
||||
var queuedMessages: [Message] = []
|
||||
var wait: Wait?
|
||||
var error: String?
|
||||
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
|
||||
}
|
||||
|
||||
struct AgentDescriptor: Decodable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
var type: String
|
||||
var model: String
|
||||
var `protocol`: String
|
||||
var activity: AgentActivity
|
||||
var websocket: String
|
||||
var capabilities: AgentCapabilities? = nil
|
||||
}
|
||||
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
|
||||
var attachmentIds: [String]? = nil
|
||||
func validate(requestID: String, attachmentIDs: [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.")
|
||||
}
|
||||
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
|
||||
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
static func decode(_ data: Data) throws -> AgentEvent? {
|
||||
struct Envelope: Decodable {
|
||||
var type: String
|
||||
var conversationId: String?
|
||||
var activity: AgentActivity?
|
||||
var error: String?
|
||||
}
|
||||
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
|
||||
switch envelope.type {
|
||||
case "ready": return .ready(envelope.conversationId)
|
||||
case "messages":
|
||||
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
|
||||
try page.validate()
|
||||
return .messages(page)
|
||||
case "activity":
|
||||
guard let id = envelope.conversationId, let activity = envelope.activity else {
|
||||
throw AgentFailure(message: "Invalid Agent activity frame.")
|
||||
}
|
||||
return .activity(id, activity)
|
||||
case "pong", "heartbeat": return .heartbeat
|
||||
case "error": return .error(envelope.error ?? "Agent connection error.")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentCoding {
|
||||
static func decoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let text = try container.decode(String.self)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: text) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
guard let date = formatter.date(from: text) else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentEntryCursor: String? {
|
||||
let value = metadata?.objectValue?["entryId"]
|
||||
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
|
||||
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
|
||||
return String(Int64(number))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var isAgentPublicReply: Bool {
|
||||
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
|
||||
}
|
||||
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
|
||||
}
|
||||
|
||||
struct AgentRoomState: Equatable, Sendable {
|
||||
var conversationID: String?
|
||||
var messages: [Message] = []
|
||||
var cursor = "0"
|
||||
var readCursor = "0"
|
||||
var activity = AgentActivity()
|
||||
var unreadCount: Int {
|
||||
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
|
||||
}
|
||||
mutating func checkConversation(_ id: String) throws {
|
||||
guard conversationID == nil || conversationID == id else {
|
||||
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
|
||||
}
|
||||
conversationID = id
|
||||
}
|
||||
mutating func apply(_ page: AgentPage) throws {
|
||||
try page.validate()
|
||||
var next = self
|
||||
try next.checkConversation(page.conversationId)
|
||||
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
|
||||
next.messages = Self.merge(messages, page.messages)
|
||||
self = next
|
||||
}
|
||||
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
|
||||
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
for var message in incoming {
|
||||
if let old = byID[message.id] {
|
||||
let prior = old.metadata?.objectValue ?? [:]
|
||||
let next = message.metadata?.objectValue ?? [:]
|
||||
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
|
||||
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
|
||||
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
|
||||
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
|
||||
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
|
||||
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
|
||||
message.metadata = .object(merged)
|
||||
message.createdAt = min(old.createdAt, message.createdAt)
|
||||
if stale { message.content = old.content; message.name = old.name }
|
||||
}
|
||||
}
|
||||
byID[message.id] = message
|
||||
}
|
||||
return byID.values.sorted {
|
||||
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
|
||||
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
|
||||
return $0.id < $1.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter only the transcript projection, before windowing. Keep the room's raw
|
||||
// messages and cursors intact for upserts, replay, unread state and activity.
|
||||
struct AgentTranscript {
|
||||
let messages: [Message]
|
||||
let startIndex: Int
|
||||
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
|
||||
|
||||
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
|
||||
let visible = messages.filter {
|
||||
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
|
||||
}
|
||||
self.messages = visible
|
||||
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
|
||||
?? max(0, visible.count - limit)
|
||||
}
|
||||
}
|
||||
|
||||
// The transcript latch is independent of the truthful sidebar/worker activity.
|
||||
struct AgentPresentation {
|
||||
private(set) var suppressed = false
|
||||
private(set) var busySince: Date?
|
||||
private var seenTools: Set<String> = []
|
||||
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
||||
|
||||
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
||||
switch event {
|
||||
case let .messages(page):
|
||||
let existing = Set(before.messages.map(\.id))
|
||||
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
|
||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||
if !replay {
|
||||
for message in fresh {
|
||||
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
||||
else if message.role == .user { suppressed = false }
|
||||
else if message.toolCallMetadata?.status == "initiated",
|
||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
||||
}
|
||||
}
|
||||
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
|
||||
case let .activity(_, activity):
|
||||
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
|
||||
seenTools.formUnion(activity.tools.map(\.id))
|
||||
default: break
|
||||
}
|
||||
reconcile(activity: after.activity, now: now)
|
||||
}
|
||||
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
|
||||
private mutating func reconcile(activity: AgentActivity, now: Date) {
|
||||
if suppressed || !activity.isBusy { busySince = nil }
|
||||
else if busySince == nil { busySince = now }
|
||||
}
|
||||
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct AgentOutboxRow: Codable, Equatable, Sendable {
|
||||
var requestID: String
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var status: String = "sending"
|
||||
var seenOnServer = false
|
||||
var confirmed = false
|
||||
var error: String?
|
||||
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
|
||||
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
|
||||
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) }
|
||||
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
|
||||
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOutboxStorage: Sendable {
|
||||
var read: @Sendable () throws -> [AgentOutboxRow]
|
||||
var write: @Sendable ([AgentOutboxRow]) throws -> Void
|
||||
|
||||
static func file(baseURL: URL) -> Self {
|
||||
// Separate installations, but never persist tokens in a filename or payload.
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
|
||||
let url = directory.appendingPathComponent("\(key).json")
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
|
||||
}, write: { rows in
|
||||
let data = try JSONEncoder().encode(rows)
|
||||
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
|
||||
throw AgentFailure(message: "Retry pending messages before sending more.")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SybilAgentRoom {
|
||||
struct Snapshot: Equatable {
|
||||
var room = AgentRoomState()
|
||||
var isTyping = false
|
||||
var entranceIDs: Set<String> = []
|
||||
var revision = 0
|
||||
}
|
||||
private(set) var snapshot = Snapshot()
|
||||
private(set) var connection = "Offline"
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
let attachmentDraft = AgentAttachmentDraft()
|
||||
private(set) var attachmentSessionID = UUID()
|
||||
private var attachmentModals: Set<UUID> = []
|
||||
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
|
||||
func setAttachmentModal(_ id: UUID, presented: Bool) {
|
||||
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
|
||||
}
|
||||
var composer: String {
|
||||
get { attachmentDraft.caption }
|
||||
set { attachmentDraft.caption = newValue }
|
||||
}
|
||||
var isObscured = false
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
|
||||
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
|
||||
@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 ?? [:]
|
||||
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
|
||||
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
|
||||
messages[index].content = row.content
|
||||
}
|
||||
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, draftStorage: AgentDraftStorage? = nil) {
|
||||
stop()
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
attachmentDraft.configure(client: client, storage: draftStorage)
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
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
|
||||
}
|
||||
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
|
||||
attachmentDraft.clearAfterSubmission()
|
||||
}
|
||||
} catch {
|
||||
outbox = []; storageReadable = false
|
||||
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
stop()
|
||||
client = nil
|
||||
attachmentDraft.updateCapability(nil)
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
attachmentDraft.suspend()
|
||||
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 { attachmentDraft.resume(); 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 }
|
||||
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
|
||||
self.attachmentDraft.resume()
|
||||
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
|
||||
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
|
||||
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
|
||||
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
|
||||
: 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, !isAttachmentModalPresented 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
|
||||
guard let client else { throw AgentAttachmentIssue.unavailable }
|
||||
let session = attachmentSessionID
|
||||
let data = try await attachmentDownloads.data(for: attachment, client: client)
|
||||
try Task.checkCancellation()
|
||||
guard attachmentSessionID == session else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func sendComposer() -> Bool {
|
||||
do {
|
||||
let attachments = try attachmentDraft.validateReady()
|
||||
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
|
||||
return true
|
||||
} catch { self.error = error.localizedDescription; return false }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
|
||||
do {
|
||||
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
|
||||
if !attachments.isEmpty {
|
||||
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
} catch { self.error = error.localizedDescription; 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(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
|
||||
outbox.append(row)
|
||||
do { if !attachments.isEmpty || draftID != nil { try persist() } }
|
||||
catch {
|
||||
outbox.removeAll { $0.id == row.id }
|
||||
self.error = "Could not save message: \(error.localizedDescription)"
|
||||
return false // Keep the entire draft when durable handoff fails.
|
||||
}
|
||||
pendingEntrances.insert(row.id)
|
||||
presenter.sent(now: Date(), activity: pending.activity)
|
||||
flush()
|
||||
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
|
||||
outbox[outbox.count - 1].status = "failed"
|
||||
outbox[outbox.count - 1].error = attachmentDraft.error
|
||||
try? persist()
|
||||
return false
|
||||
}
|
||||
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 }
|
||||
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { 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 attachments = row.attachments ?? []
|
||||
if !attachments.isEmpty {
|
||||
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
|
||||
let receipt = try await client.agentSubmit(submission)
|
||||
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
|
||||
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 = pending.messages
|
||||
let queued = pending.activity.queuedMessages
|
||||
var changed = false
|
||||
for index in outbox.indices {
|
||||
let row = outbox[index]
|
||||
let matches: (Message) -> Bool = { message in
|
||||
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
|
||||
}
|
||||
if committed.contains(where: matches) {
|
||||
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(where: matches), !outbox[index].confirmed {
|
||||
changed = true
|
||||
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
||||
}
|
||||
}
|
||||
if changed { try? persist() }
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
protocol AgentSocket: Sendable {
|
||||
func receive() async throws -> Data
|
||||
func send(_ text: String) async throws
|
||||
func close() async
|
||||
func closeCode() async -> Int
|
||||
}
|
||||
|
||||
protocol AgentAPIClienting: Sendable {
|
||||
func agentDescriptor() async throws -> AgentDescriptor
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Text-only test/legacy clients fail closed for the new wire path.
|
||||
extension AgentAPIClienting {
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
|
||||
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
|
||||
}
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
|
||||
}
|
||||
|
||||
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
// Reject every redirect; attachment credentials are valid only for the configured API.
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIConfiguration {
|
||||
func agentAttachmentContentURL(id: String) throws -> URL {
|
||||
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
|
||||
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
|
||||
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
|
||||
parts.query = nil; parts.fragment = nil
|
||||
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SybilAgentView: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var topInset: CGFloat = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
@State private var scrollMetrics = AgentScrollMetrics()
|
||||
@State private var rowFrames: [String: CGRect] = [:]
|
||||
@State private var restoreAnchor: AgentReadingAnchor?
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rendered = room.snapshot
|
||||
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
||||
let allMessages = transcript.messages
|
||||
let startIndex = transcript.startIndex
|
||||
let displayed = transcript.displayed
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if var anchor = visibleReadingAnchor {
|
||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
||||
restoreAnchor = anchor
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
VStack(spacing: 14) {
|
||||
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, room: room, 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(.bottom, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.top, 16 + topInset)
|
||||
.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,
|
||||
visibleBottom: geometry.visibleRect.maxY)
|
||||
} 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 anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Restore the exact pixel offset after insertion or text
|
||||
// reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 {
|
||||
// Geometry reports the raw offset; ScrollPosition uses
|
||||
// zero at the top inset, including the safe area.
|
||||
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
|
||||
}
|
||||
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
|
||||
}
|
||||
.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, !room.isAttachmentModalPresented, 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, !room.isAttachmentModalPresented, 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.attachmentDraft.canSend
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AgentAttachmentComposer(room: room)
|
||||
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.sendComposer() else { return }
|
||||
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 room: SybilAgentRoom
|
||||
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) {
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
}
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
Text(message.createdAt, style: .time)
|
||||
if own && status == "sending" { Text("Sending…") }
|
||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||
if own && status == "failed" {
|
||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
|
||||
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animate both occupied height and content, so incoming rows push older history up.
|
||||
private struct AgentGrowingLayout: Layout {
|
||||
var progress: CGFloat
|
||||
var animatableData: CGFloat { get { progress } set { progress = newValue } }
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
|
||||
}
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
private struct AgentEntrance: ViewModifier {
|
||||
var animate: Bool
|
||||
var reduceMotion: Bool
|
||||
@State private var entered = false
|
||||
func body(content: Content) -> some View {
|
||||
let preparing = animate && !entered && !reduceMotion
|
||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentScrollMetrics: Equatable {
|
||||
var offset: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var visibleBottom: CGFloat = 0
|
||||
// visibleRect includes the viewport extended into the safe area, as well as
|
||||
// the alignment inset used when a short transcript is pinned to the bottom.
|
||||
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
||||
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
||||
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
var offset: CGFloat
|
||||
var expectedCount: Int? = nil
|
||||
}
|
||||
private struct AgentRowFrames: PreferenceKey {
|
||||
static let defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
var isForking: Bool = false
|
||||
var onForkResponse: ((String) -> Void)? = nil
|
||||
|
||||
@State private var hasTrackedToolCallMessages = false
|
||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||
@@ -45,15 +41,7 @@ struct SybilChatTranscriptView: View {
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
@@ -70,8 +58,7 @@ struct SybilChatTranscriptView: View {
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
}
|
||||
@@ -79,6 +66,7 @@ struct SybilChatTranscriptView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
syncKnownToolCallMessageIDs()
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
}
|
||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||
syncKnownToolCallMessageIDs()
|
||||
@@ -149,13 +137,9 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
||||
return items
|
||||
}
|
||||
|
||||
struct MessageBubble: View {
|
||||
private struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
var agentStyle = false
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -165,46 +149,13 @@ struct MessageBubble: View {
|
||||
message.role == .user
|
||||
}
|
||||
|
||||
private var isStreamingAssistant: Bool {
|
||||
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
private var canFork: Bool {
|
||||
message.role == .assistant &&
|
||||
!message.id.hasPrefix("temp-") &&
|
||||
onFork != nil
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if canFork {
|
||||
bubbleContent
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = message.content
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
onFork?()
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(isForking)
|
||||
}
|
||||
} else {
|
||||
bubbleContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bubbleContent: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
@@ -223,26 +174,25 @@ struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isStreamingAssistant {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
.textSelection(.enabled)
|
||||
.background(
|
||||
Group {
|
||||
@@ -253,10 +203,6 @@ struct MessageBubble: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
} else if agentStyle {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(Color.clear)
|
||||
@@ -286,123 +232,6 @@ struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantContentView: View {
|
||||
private enum Phase: Hashable {
|
||||
case pending
|
||||
case trace(Int)
|
||||
case answer
|
||||
}
|
||||
|
||||
var content: String
|
||||
var isSending: Bool
|
||||
var presentation: StreamingAssistantPresentation?
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var retainedTraceHeight: CGFloat = 0
|
||||
|
||||
private var traceText: String? {
|
||||
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trace.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
private var hasAnswer: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var phase: Phase {
|
||||
if hasAnswer {
|
||||
return .answer
|
||||
}
|
||||
if traceText != nil {
|
||||
return .trace(presentation?.traceRevision ?? 0)
|
||||
}
|
||||
return .pending
|
||||
}
|
||||
|
||||
private var traceTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .asymmetric(
|
||||
insertion: .opacity.combined(with: .offset(y: 5)),
|
||||
removal: .opacity.combined(with: .offset(y: -5))
|
||||
)
|
||||
}
|
||||
|
||||
private var answerTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .opacity.combined(with: .offset(y: 3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let traceText {
|
||||
Text(traceText)
|
||||
.font(.sybil(size: 15))
|
||||
.italic()
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.opacity(0.78)
|
||||
.textSelection(.disabled)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityLabel("Assistant is working")
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: StreamingTraceHeightPreferenceKey.self,
|
||||
value: geometry.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
|
||||
.transition(traceTransition)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.transition(answerTransition)
|
||||
.zIndex(2)
|
||||
}
|
||||
|
||||
if isSending && !hasAnswer && traceText == nil {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.transition(reduceMotion ? .identity : .opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
|
||||
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
|
||||
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
|
||||
retainedTraceHeight = measuredHeight
|
||||
}
|
||||
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
@@ -611,7 +440,7 @@ private struct ToolCallStackCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallActivityChip: View {
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// This Foundation-only contract is compiled into both the Catalyst client and the
|
||||
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelBridging)
|
||||
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
||||
init()
|
||||
var isVisible: Bool { get }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
||||
func updateState(_ data: Data)
|
||||
func show()
|
||||
func hide()
|
||||
func activateForQuickQuestion()
|
||||
func activateMainWindow()
|
||||
func stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelDelegate)
|
||||
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
||||
func quickQuestionToggleRequested()
|
||||
func quickQuestionPromptChanged(_ prompt: String)
|
||||
func quickQuestionSubmitRequested()
|
||||
func quickQuestionProviderChanged(_ provider: String)
|
||||
func quickQuestionModelChanged(_ model: String)
|
||||
func quickQuestionConvertRequested()
|
||||
func quickQuestionOpenAppRequested()
|
||||
func quickQuestionPanelDismissed()
|
||||
}
|
||||
|
||||
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
||||
struct ProviderOption: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
}
|
||||
|
||||
var prompt = ""
|
||||
var answer = ""
|
||||
var toolSummaries: [String] = []
|
||||
var provider = ""
|
||||
var providers: [ProviderOption] = []
|
||||
var model = ""
|
||||
var models: [String] = []
|
||||
var isSending = false
|
||||
var isConverting = false
|
||||
var canSend = false
|
||||
var canConvert = false
|
||||
var isAuthenticated = false
|
||||
var isCheckingSession = true
|
||||
var error: String?
|
||||
|
||||
var isBusy: Bool { isSending || isConverting }
|
||||
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
||||
}
|
||||
|
||||
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
||||
// per press/release pair, without suppressing a second deliberate press.
|
||||
struct SybilQuickQuestionHotKeyState {
|
||||
private var isPressed = false
|
||||
|
||||
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
||||
if !isKeyDown {
|
||||
isPressed = false
|
||||
return false
|
||||
}
|
||||
guard !isPressed else { return false }
|
||||
isPressed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
||||
public static let shared = SybilMacQuickQuestionController()
|
||||
|
||||
private var pluginBundle: Bundle?
|
||||
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
||||
private var viewModel: SybilViewModel?
|
||||
private var observationGeneration = 0
|
||||
private var isPresentationRequested = false
|
||||
private var activationRequested = false
|
||||
private var presentationTask: Task<Void, Never>?
|
||||
private var foregroundObservation: AnyCancellable?
|
||||
private var notificationCenter = NotificationCenter.default
|
||||
private var isApplicationInForeground: @MainActor () -> Bool = {
|
||||
UIApplication.shared.applicationState != .background
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// Inject foreground readiness as well as the panel so tests can exercise
|
||||
// windowless activation without activating apps or showing real windows.
|
||||
init(
|
||||
bridge: any SybilQuickQuestionPanelBridging,
|
||||
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
||||
notificationCenter: NotificationCenter = NotificationCenter()
|
||||
) {
|
||||
self.bridge = bridge
|
||||
self.isApplicationInForeground = isApplicationInForeground
|
||||
self.notificationCenter = notificationCenter
|
||||
super.init()
|
||||
_ = bridge.start(delegate: self)
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
guard bridge == nil else { return }
|
||||
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
||||
let bundle = Bundle(url: url)
|
||||
else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try bundle.loadAndReturnError()
|
||||
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
||||
return
|
||||
}
|
||||
let plugin = pluginClass.init()
|
||||
pluginBundle = bundle
|
||||
bridge = plugin
|
||||
let status = plugin.start(delegate: self)
|
||||
if status != 0 {
|
||||
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ viewModel: SybilViewModel) {
|
||||
guard self.viewModel !== viewModel else { return }
|
||||
// A window becoming active must not replace an in-flight quick question.
|
||||
if self.viewModel != nil,
|
||||
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
||||
return
|
||||
}
|
||||
self.viewModel = viewModel
|
||||
if bridge?.isVisible == true { updateAndObserve() }
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
start()
|
||||
if isPresentationRequested || bridge?.isVisible == true {
|
||||
cancelPresentation()
|
||||
bridge?.hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
start()
|
||||
isPresentationRequested = true
|
||||
schedulePresentationIfNeeded()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
cancelPresentation()
|
||||
foregroundObservation = nil
|
||||
viewModel?.cancelQuickQuestion()
|
||||
bridge?.stop()
|
||||
bridge = nil
|
||||
// Keep the loaded bundle alive for the lifetime of its code.
|
||||
}
|
||||
|
||||
private func observeForegroundTransitionsIfNeeded() {
|
||||
guard foregroundObservation == nil else { return }
|
||||
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.schedulePresentationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePresentationIfNeeded() {
|
||||
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
||||
// Leave the Carbon/NSApplication event callback before touching windows.
|
||||
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
||||
// is not a substitute for checking UIKit's foreground lifecycle state.
|
||||
presentationTask = Task { @MainActor [weak self] in
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.presentationTask = nil
|
||||
guard self.isPresentationRequested else { return }
|
||||
guard self.isApplicationInForeground() else {
|
||||
guard !self.activationRequested else { return }
|
||||
self.activationRequested = true
|
||||
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
||||
self.bridge?.activateForQuickQuestion()
|
||||
// Also handle an activation that completes synchronously. If it
|
||||
// has not completed, subsequent UIKit notifications resume us.
|
||||
self.schedulePresentationIfNeeded()
|
||||
return
|
||||
}
|
||||
self.activationRequested = false
|
||||
self.updateAndObserve()
|
||||
self.bridge?.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPresentation() {
|
||||
isPresentationRequested = false
|
||||
activationRequested = false
|
||||
presentationTask?.cancel()
|
||||
presentationTask = nil
|
||||
observationGeneration += 1
|
||||
}
|
||||
|
||||
private func updateAndObserve() {
|
||||
guard let bridge else { return }
|
||||
observationGeneration += 1
|
||||
let generation = observationGeneration
|
||||
let state = withObservationTracking {
|
||||
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
do {
|
||||
bridge.updateState(try JSONEncoder().encode(state))
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionToggleRequested() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
func quickQuestionPromptChanged(_ prompt: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.updateQuickQuestionPrompt(prompt)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionSubmitRequested() {
|
||||
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
||||
viewModel.sendQuickQuestion()
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionProviderChanged(_ provider: String) {
|
||||
guard let viewModel, let provider = Provider(rawValue: provider),
|
||||
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionProvider(provider)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionModelChanged(_ model: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionModel(model)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionConvertRequested() {
|
||||
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await viewModel.convertQuickQuestionToChat() {
|
||||
self.cancelPresentation()
|
||||
self.openMainWindow()
|
||||
self.bridge?.hide()
|
||||
} else {
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionOpenAppRequested() {
|
||||
cancelPresentation()
|
||||
openMainWindow()
|
||||
bridge?.hide()
|
||||
}
|
||||
|
||||
private func openMainWindow() {
|
||||
let session = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.session
|
||||
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
||||
bridge?.activateMainWindow()
|
||||
}
|
||||
|
||||
func quickQuestionPanelDismissed() {
|
||||
// Toggling out preserves the draft and lets an answer finish. Reopening
|
||||
// reads the latest state, without starting a new request.
|
||||
cancelPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
extension SybilViewModel {
|
||||
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
||||
SybilQuickQuestionPanelState(
|
||||
prompt: quickQuestionPrompt,
|
||||
answer: quickQuestionAnswerText,
|
||||
toolSummaries: quickQuestionMessages.compactMap { message in
|
||||
guard let metadata = message.toolCallMetadata else { return nil }
|
||||
return metadata.summary ?? message.content
|
||||
},
|
||||
provider: quickQuestionProvider.rawValue,
|
||||
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
||||
model: quickQuestionModel,
|
||||
models: quickQuestionProviderModelOptions,
|
||||
isSending: isQuickQuestionSending,
|
||||
isConverting: isConvertingQuickQuestion,
|
||||
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
||||
canConvert: canConvertQuickQuestion,
|
||||
isAuthenticated: isAuthenticated,
|
||||
isCheckingSession: isCheckingSession,
|
||||
error: quickQuestionError
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,37 +0,0 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
|
||||
// Scene lifetime is independent of focused commands: a connected window may
|
||||
// still be authenticating, inactive, or minimized. The native Quick Question
|
||||
// panel is not a workspace scene and does not count as an open window here.
|
||||
@MainActor
|
||||
public final class SybilMacWindowCommands: ObservableObject {
|
||||
public static let shared = SybilMacWindowCommands()
|
||||
|
||||
@Published private var connectedWindowIDs: Set<String> = []
|
||||
|
||||
init() {}
|
||||
|
||||
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
||||
|
||||
public func windowConnected(sessionID: String) {
|
||||
connectedWindowIDs.insert(sessionID)
|
||||
}
|
||||
|
||||
public func windowDisconnected(sessionID: String) {
|
||||
connectedWindowIDs.remove(sessionID)
|
||||
}
|
||||
|
||||
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
||||
!hasOpenWindows || hasFocusedActions
|
||||
}
|
||||
|
||||
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
||||
if hasOpenWindows {
|
||||
newChat?()
|
||||
} else {
|
||||
openWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,7 +4,6 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case openai
|
||||
case anthropic
|
||||
case xai
|
||||
case gemini
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
@@ -12,7 +11,6 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case .openai: return "OpenAI"
|
||||
case .anthropic: return "Anthropic"
|
||||
case .xai: return "xAI"
|
||||
case .gemini: return "Gemini"
|
||||
case .hermesAgent: return "Hermes Agent"
|
||||
}
|
||||
}
|
||||
@@ -154,8 +152,6 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -166,39 +162,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var lastUsedModel: String?
|
||||
}
|
||||
|
||||
extension ChatSummary {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
@@ -219,9 +182,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var query: String?
|
||||
public var searchSnippet: String? = nil
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -236,8 +196,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
self.id = chat.id
|
||||
self.title = chat.title
|
||||
self.query = nil
|
||||
self.parentChatId = chat.parentChatId
|
||||
self.titleGenerationPending = chat.titleGenerationPending
|
||||
self.createdAt = chat.createdAt
|
||||
self.updatedAt = chat.updatedAt
|
||||
self.starred = chat.starred
|
||||
@@ -253,8 +211,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
self.id = search.id
|
||||
self.title = search.title
|
||||
self.query = search.query
|
||||
self.parentChatId = nil
|
||||
self.titleGenerationPending = false
|
||||
self.createdAt = search.createdAt
|
||||
self.updatedAt = search.updatedAt
|
||||
self.starred = search.starred
|
||||
@@ -270,8 +226,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
return ChatSummary(
|
||||
id: id,
|
||||
title: title,
|
||||
parentChatId: parentChatId,
|
||||
titleGenerationPending: titleGenerationPending,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
starred: starred,
|
||||
@@ -297,45 +251,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkspaceItem {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case id
|
||||
case title
|
||||
case query
|
||||
case searchSnippet
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
type = try container.decode(WorkspaceItemType.self, forKey: .type)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
query = try container.decodeIfPresent(String.self, forKey: .query)
|
||||
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var createdAt: Date
|
||||
@@ -359,14 +274,9 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
toolName: object["toolName"]?.stringValue ?? name,
|
||||
status: object["status"]?.stringValue,
|
||||
summary: object["summary"]?.stringValue,
|
||||
durationMs: object["durationMs"]?.numberValue.flatMap { value in
|
||||
value >= 0 && value < Double(Int.max) ? Int(value) : nil
|
||||
},
|
||||
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||
error: object["error"]?.stringValue,
|
||||
resultPreview: object["resultPreview"]?.stringValue,
|
||||
args: object["args"],
|
||||
result: object["result"],
|
||||
startedAt: object["startedAt"]?.stringValue
|
||||
resultPreview: object["resultPreview"]?.stringValue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -383,9 +293,6 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
public var args: JSONValue? = nil
|
||||
public var result: JSONValue? = nil
|
||||
public var startedAt: String? = nil
|
||||
}
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
@@ -482,8 +389,6 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
public var parentChatId: String? = nil
|
||||
public var titleGenerationPending = false
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
@@ -495,41 +400,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var messages: [Message]
|
||||
}
|
||||
|
||||
extension ChatDetail {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case parentChatId
|
||||
case titleGenerationPending
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case starred
|
||||
case starredAt
|
||||
case initiatedProvider
|
||||
case initiatedModel
|
||||
case lastUsedProvider
|
||||
case lastUsedModel
|
||||
case messages
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||
messages = try container.decode([Message].self, forKey: .messages)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var createdAt: Date
|
||||
@@ -741,11 +611,6 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceSearchResponse: Codable, Sendable {
|
||||
var items: [WorkspaceItem]
|
||||
var hasMore: Bool
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
enum PhoneRoute: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case draftChat
|
||||
@@ -15,7 +14,6 @@ enum PhoneRoute: Hashable {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .settings:
|
||||
return .settings
|
||||
}
|
||||
@@ -66,7 +64,6 @@ struct SybilPhoneShellView: View {
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .agent: return .agent
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
@@ -90,10 +87,6 @@ struct SybilPhoneShellView: View {
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
@@ -137,10 +130,7 @@ struct SybilPhoneShellView: View {
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay,
|
||||
onOpenForkedChat: { chatID in
|
||||
showRoute(.chat(chatID))
|
||||
}
|
||||
onShowSidebar: showSidebarOverlay
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
@@ -249,7 +239,6 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||
dismissSidebarKeyboard()
|
||||
let update = {
|
||||
route = nextRoute
|
||||
}
|
||||
@@ -279,19 +268,12 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
|
||||
private func hideSidebarOverlay() {
|
||||
dismissSidebarKeyboard()
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func dismissSidebarKeyboard() {
|
||||
if isSidebarOverlayPresented {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||
return
|
||||
@@ -529,12 +511,7 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
},
|
||||
onSelectAgent: { onSelect(.agent) },
|
||||
isAgentSelected: highlightedSelection == .agent
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -608,7 +585,6 @@ private struct SybilPhoneDestinationView: View {
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
let onOpenForkedChat: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
@@ -617,8 +593,7 @@ private struct SybilPhoneDestinationView: View {
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat,
|
||||
onOpenForkedChat: onOpenForkedChat
|
||||
onRequestNewChat: onRequestNewChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task(id: route) {
|
||||
@@ -638,8 +613,6 @@ private struct SybilPhoneDestinationView: View {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .agent:
|
||||
viewModel.select(.agent)
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
case .draftSearch:
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SybilPromptField: View {
|
||||
var placeholder: String = "Enter Prompt"
|
||||
@Binding var text: String
|
||||
@FocusState.Binding var focused: Bool
|
||||
var selection: Binding<TextSelection?>? = nil
|
||||
var axis: Axis = .vertical
|
||||
var submit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let selection {
|
||||
TextField(placeholder, text: $text, selection: selection, axis: axis)
|
||||
} else {
|
||||
TextField(placeholder, text: $text, axis: axis)
|
||||
}
|
||||
}
|
||||
.focused($focused)
|
||||
.font(.sybil(.body))
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submit)
|
||||
.submitOnMacReturn(submit)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilPromptSendButton: View {
|
||||
var systemImage: String = "arrow.up"
|
||||
var canSend: Bool
|
||||
var submit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: submit) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
canSend
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSend)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ struct SybilQuickQuestionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var promptSelection: TextSelection?
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
@@ -37,8 +36,6 @@ struct SybilQuickQuestionView: View {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
let prompt = viewModel.quickQuestionPrompt
|
||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +59,7 @@ struct SybilQuickQuestionView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if hasAnswerContent {
|
||||
ForEach(viewModel.quickQuestionMessages) { message in
|
||||
QuickQuestionMessageView(
|
||||
message: message,
|
||||
isSending: viewModel.isQuickQuestionSending,
|
||||
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
|
||||
)
|
||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
if let error = viewModel.quickQuestionError {
|
||||
@@ -95,19 +88,46 @@ struct SybilQuickQuestionView: View {
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
placeholder: "Ask anything...",
|
||||
TextField(
|
||||
"Ask anything...",
|
||||
text: Binding(
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
focused: $promptFocused,
|
||||
selection: $promptSelection,
|
||||
axis: .horizontal,
|
||||
submit: submitQuestion
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($promptFocused)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submitQuestion)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: submitQuestion)
|
||||
Button(action: submitQuestion) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendQuickQuestion
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendQuickQuestion)
|
||||
.accessibilityLabel("Ask quick question")
|
||||
}
|
||||
|
||||
@@ -239,11 +259,11 @@ private struct QuickQuestionPickerPill: View {
|
||||
private struct QuickQuestionMessageView: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -252,12 +272,15 @@ private struct QuickQuestionMessageView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.font(.body)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// A vertical TextField treats Return as editing, not form submission. Handle
|
||||
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
||||
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
||||
@MainActor
|
||||
enum SybilReturnKeySubmission {
|
||||
static func handle(
|
||||
phase: KeyPress.Phases,
|
||||
modifiers: EventModifiers,
|
||||
submit: () -> Void
|
||||
) -> KeyPress.Result {
|
||||
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
||||
return .ignored
|
||||
}
|
||||
if phase == .down {
|
||||
submit()
|
||||
}
|
||||
// Consume autorepeat without submitting or inserting a newline.
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
||||
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,7 @@ struct SybilSearchResultsView: View {
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 20 + topContentInset)
|
||||
.padding(.bottom, 20 + bottomContentInset)
|
||||
|
||||
@@ -11,13 +11,11 @@ final class SybilSettingsStore {
|
||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||
}
|
||||
|
||||
@@ -46,18 +44,16 @@ final class SybilSettingsStore {
|
||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
|
||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||
]
|
||||
self.preferredModelByProvider = preferredModels
|
||||
|
||||
self.quickQuestionPreferredProvider =
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||
self.quickQuestionPreferredModelByProvider = [
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||
]
|
||||
}
|
||||
@@ -76,14 +72,12 @@ final class SybilSettingsStore {
|
||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||
|
||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSidebarView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -112,12 +111,8 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
var onSelectAgent: (() -> Void)? = nil
|
||||
var isAgentSelected: Bool? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
@@ -131,122 +126,37 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
AgentAvatar()
|
||||
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
|
||||
if viewModel.agent.unreadCount > 0 {
|
||||
Text(String(viewModel.agent.unreadCount))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(SybilTheme.primary, in: Capsule())
|
||||
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(12)
|
||||
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("agent-sidebar")
|
||||
.padding(.horizontal, 10).padding(.top, 10)
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
TextField("Search chats and web searches", text: Binding(
|
||||
get: { viewModel.sidebarSearchQuery },
|
||||
set: { viewModel.setSidebarSearchQuery($0) }
|
||||
))
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.focused($isSearchFocused)
|
||||
.accessibilityLabel("Search conversation history and saved web searches")
|
||||
.onSubmit {
|
||||
isSearchFocused = false
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
|
||||
if !viewModel.sidebarSearchQuery.isEmpty {
|
||||
Button {
|
||||
viewModel.setSidebarSearchQuery("")
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear history search")
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
|
||||
.padding(10)
|
||||
|
||||
itemList
|
||||
}
|
||||
}
|
||||
|
||||
private var itemList: some View {
|
||||
Group {
|
||||
if viewModel.isSearchingSidebar ||
|
||||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
||||
Text("Loading conversations…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
|
||||
VStack(spacing: 12) {
|
||||
Text(error)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Try again") {
|
||||
Task { await viewModel.refreshSidebarSearch() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else if viewModel.visibleSidebarItems.isEmpty {
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
||||
Image(systemName: "message.badge")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if viewModel.isSidebarSearchActive {
|
||||
Text("Try a title, a query, or words from a conversation.")
|
||||
.font(.sybil(.caption))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(viewModel.visibleSidebarItems) { item in
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
Button {
|
||||
isSearchFocused = false
|
||||
onSelect(item)
|
||||
} label: {
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
@@ -258,26 +168,13 @@ struct SybilSidebarItemList: View {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if case let .chat(chatID) = item.selection {
|
||||
Button {
|
||||
forkChat(item)
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = chatID
|
||||
} label: {
|
||||
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
@@ -289,16 +186,9 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
|
||||
Text("Showing the 100 most recent matches. Refine your search to find more.")
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
@@ -325,52 +215,11 @@ struct SybilSidebarItemList: View {
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func forkChat(_ item: SidebarItem) {
|
||||
guard case let .chat(chatID) = item.selection else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
let draftKindAtStart = viewModel.draftKind
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == draftKindAtStart else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
var isSelected: Bool
|
||||
var searchQuery = ""
|
||||
|
||||
private func highlightedSnippet(_ snippet: String) -> AttributedString {
|
||||
var highlighted = AttributedString(snippet)
|
||||
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
|
||||
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
|
||||
guard let range = Range(match.range, in: searchQuery) else { return nil }
|
||||
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
}.filter { !$0.isEmpty }
|
||||
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
|
||||
guard let range = Range(word.range, in: snippet) else { continue }
|
||||
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
guard terms.contains(where: { normalized.hasPrefix($0) }),
|
||||
let start = AttributedString.Index(range.lowerBound, within: highlighted),
|
||||
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
|
||||
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
|
||||
}
|
||||
return highlighted
|
||||
}
|
||||
|
||||
private var isHighlighted: Bool {
|
||||
isSelected
|
||||
@@ -432,15 +281,6 @@ struct SybilSidebarRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
if let snippet = item.searchSnippet, !snippet.isEmpty {
|
||||
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -64,10 +64,6 @@ extension Font {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilLayout {
|
||||
static let webContentMaxWidth: CGFloat = 896
|
||||
}
|
||||
|
||||
enum SybilTheme {
|
||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||
|
||||
@@ -7,7 +7,6 @@ enum DraftKind {
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case agent
|
||||
case chat(String)
|
||||
case search(String)
|
||||
case settings
|
||||
@@ -18,8 +17,6 @@ enum SidebarSelection: Hashable {
|
||||
return "chat:\(chatID)"
|
||||
case let .search(searchID):
|
||||
return "search:\(searchID)"
|
||||
case .agent:
|
||||
return "agent"
|
||||
case .settings:
|
||||
return "settings"
|
||||
}
|
||||
@@ -41,75 +38,11 @@ struct SidebarItem: Identifiable, Hashable {
|
||||
var starredAt: Date?
|
||||
var initiatedLabel: String?
|
||||
var isRunning: Bool
|
||||
var searchSnippet: String? = nil
|
||||
}
|
||||
|
||||
enum StreamingAssistantContentUpdate: Equatable, Sendable {
|
||||
case unchanged
|
||||
case replace(String)
|
||||
}
|
||||
|
||||
struct StreamingAssistantState: Equatable, Sendable {
|
||||
private(set) var segmentText = ""
|
||||
private(set) var initiatedToolCallIDs: Set<String> = []
|
||||
private(set) var ephemeralTrace: String?
|
||||
private(set) var traceRevision = 0
|
||||
|
||||
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
guard !text.isEmpty else { return .unchanged }
|
||||
segmentText += text
|
||||
ephemeralTrace = nil
|
||||
return .replace(segmentText)
|
||||
}
|
||||
|
||||
mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate {
|
||||
guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else {
|
||||
return .unchanged
|
||||
}
|
||||
|
||||
let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
segmentText = ""
|
||||
if !trace.isEmpty {
|
||||
ephemeralTrace = trace
|
||||
traceRevision += 1
|
||||
}
|
||||
return .replace("")
|
||||
}
|
||||
|
||||
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .replace(text)
|
||||
}
|
||||
|
||||
mutating func receiveError() -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .unchanged
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantPresentation: Equatable, Sendable {
|
||||
var messageID: String
|
||||
var ephemeralTrace: String?
|
||||
var traceRevision: Int
|
||||
}
|
||||
|
||||
private struct PendingChatState {
|
||||
var chatID: String?
|
||||
var messages: [Message]
|
||||
var streamingAssistantState = StreamingAssistantState()
|
||||
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: streamingAssistantState.ephemeralTrace,
|
||||
traceRevision: streamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ActiveSendContext: Hashable {
|
||||
@@ -156,7 +89,6 @@ private actor SearchStreamStatus {
|
||||
@Observable
|
||||
final class SybilViewModel {
|
||||
let settings: SybilSettingsStore
|
||||
let agent = SybilAgentRoom()
|
||||
|
||||
var isCheckingSession = true
|
||||
var isAuthenticated = false
|
||||
@@ -166,15 +98,6 @@ final class SybilViewModel {
|
||||
var chats: [ChatSummary] = []
|
||||
var searches: [SearchSummary] = []
|
||||
var workspaceItems: [WorkspaceItem] = []
|
||||
private(set) var sidebarSearchQuery = ""
|
||||
private(set) var sidebarSearchResults: [WorkspaceItem] = []
|
||||
private(set) var isSearchingSidebar = false
|
||||
private(set) var sidebarSearchError: String?
|
||||
private(set) var sidebarSearchHasMore = false
|
||||
@ObservationIgnored
|
||||
private var sidebarSearchTask: Task<Void, Never>?
|
||||
@ObservationIgnored
|
||||
private var sidebarSearchRequestID = UUID()
|
||||
|
||||
var selectedItem: SidebarSelection?
|
||||
var selectedChat: ChatDetail?
|
||||
@@ -184,7 +107,6 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var isForkingChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -203,7 +125,6 @@ final class SybilViewModel {
|
||||
var quickQuestionSubmittedModel: String?
|
||||
var isQuickQuestionSending = false
|
||||
var isConvertingQuickQuestion = false
|
||||
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
|
||||
@ObservationIgnored
|
||||
private var hasBootstrapped = false
|
||||
@@ -236,10 +157,9 @@ final class SybilViewModel {
|
||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||
|
||||
private let fallbackModels: [Provider: [String]] = [
|
||||
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||
.openai: ["gpt-4.1-mini"],
|
||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||
.xai: ["grok-3-mini"],
|
||||
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
.hermesAgent: ["hermes-agent"]
|
||||
]
|
||||
|
||||
@@ -343,8 +263,6 @@ final class SybilViewModel {
|
||||
}
|
||||
return "Search"
|
||||
|
||||
case .agent:
|
||||
return "Sybil Agent"
|
||||
case .settings:
|
||||
return "Settings"
|
||||
}
|
||||
@@ -384,7 +302,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var showsComposer: Bool {
|
||||
if selectedItem == .agent { return false }
|
||||
if case .settings = selectedItem {
|
||||
return false
|
||||
}
|
||||
@@ -442,30 +359,6 @@ final class SybilViewModel {
|
||||
return canonical
|
||||
}
|
||||
|
||||
var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
if case let .chat(chatID) = selectedItem,
|
||||
let pending = pendingChatStates[chatID] {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
if draftKind == .chat, let pending = pendingDraftChatState {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
|
||||
traceRevision: quickQuestionStreamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
|
||||
var displayedSearch: SearchDetail? {
|
||||
if case let .search(searchID) = selectedItem,
|
||||
let activeSearch = activeSearchDetails[searchID] {
|
||||
@@ -499,19 +392,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var sidebarItems: [SidebarItem] {
|
||||
makeSidebarItems(workspaceItems)
|
||||
}
|
||||
|
||||
var isSidebarSearchActive: Bool {
|
||||
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var visibleSidebarItems: [SidebarItem] {
|
||||
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
|
||||
}
|
||||
|
||||
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
|
||||
items.map { item in
|
||||
workspaceItems.map { item in
|
||||
switch item.type {
|
||||
case .chat:
|
||||
let initiatedLabel: String?
|
||||
@@ -524,21 +405,16 @@ final class SybilViewModel {
|
||||
} else {
|
||||
initiatedLabel = nil
|
||||
}
|
||||
let starOwner = item.parentChatId.flatMap { parentChatID in
|
||||
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
|
||||
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == parentChatID })
|
||||
} ?? item
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(item.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: item.title, messages: nil),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: starOwner.starred,
|
||||
starredAt: starOwner.starredAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(item.id),
|
||||
searchSnippet: item.searchSnippet
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
)
|
||||
|
||||
case .search:
|
||||
@@ -550,70 +426,20 @@ final class SybilViewModel {
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(item.id),
|
||||
searchSnippet: item.searchSnippet
|
||||
isRunning: isSearchRowRunning(item.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setSidebarSearchQuery(_ query: String) {
|
||||
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
sidebarSearchQuery = query
|
||||
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
|
||||
scheduleSidebarSearch()
|
||||
}
|
||||
|
||||
func refreshSidebarSearch() async {
|
||||
guard isSidebarSearchActive else { return }
|
||||
scheduleSidebarSearch(debounce: false)
|
||||
await sidebarSearchTask?.value
|
||||
}
|
||||
|
||||
private func scheduleSidebarSearch(debounce: Bool = true) {
|
||||
sidebarSearchTask?.cancel()
|
||||
let requestID = UUID()
|
||||
sidebarSearchRequestID = requestID
|
||||
sidebarSearchResults = []
|
||||
sidebarSearchError = nil
|
||||
sidebarSearchHasMore = false
|
||||
isSearchingSidebar = false
|
||||
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty, isAuthenticated else { return }
|
||||
guard query.utf16.count <= 200 else {
|
||||
sidebarSearchError = "Use 200 characters or fewer."
|
||||
return
|
||||
}
|
||||
isSearchingSidebar = true
|
||||
sidebarSearchTask = Task { [weak self] in
|
||||
do {
|
||||
if debounce { try await Task.sleep(for: .milliseconds(300)) }
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let response = try await self.client().searchWorkspaceItems(query: query)
|
||||
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||
self.sidebarSearchResults = response.items
|
||||
self.sidebarSearchHasMore = response.hasMore
|
||||
self.isSearchingSidebar = false
|
||||
} catch {
|
||||
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||
self.isSearchingSidebar = false
|
||||
if !self.isCancellation(error) {
|
||||
self.sidebarSearchError = self.normalizeAPIError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedChatSummary: ChatSummary? {
|
||||
guard case let .chat(chatID) = selectedItem else { return nil }
|
||||
return chats.first(where: { $0.id == chatID }) ??
|
||||
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
|
||||
return chats.first(where: { $0.id == chatID })
|
||||
}
|
||||
|
||||
var selectedSearchSummary: SearchSummary? {
|
||||
guard case let .search(searchID) = selectedItem else { return nil }
|
||||
return searches.first(where: { $0.id == searchID }) ??
|
||||
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
|
||||
return searches.first(where: { $0.id == searchID })
|
||||
}
|
||||
|
||||
var hasRefreshableSelection: Bool {
|
||||
@@ -624,7 +450,7 @@ final class SybilViewModel {
|
||||
switch selectedItem {
|
||||
case .chat, .search:
|
||||
return true
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -640,8 +466,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func reconnect() async {
|
||||
agent.disconnect()
|
||||
setSidebarSearchQuery("")
|
||||
isCheckingSession = true
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
@@ -679,10 +503,6 @@ final class SybilViewModel {
|
||||
)
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
} catch {
|
||||
stopActiveRunPolling()
|
||||
@@ -796,7 +616,6 @@ final class SybilViewModel {
|
||||
quickQuestionTask = nil
|
||||
quickQuestionRunID = nil
|
||||
isQuickQuestionSending = false
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -813,12 +632,9 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
cancelQuickQuestion()
|
||||
// Lock submission before the task starts, including repeated Return
|
||||
// events delivered in the same main-actor turn.
|
||||
isQuickQuestionSending = true
|
||||
let selectedProvider = quickQuestionProvider
|
||||
let task = Task { [weak self] in
|
||||
guard let self, !Task.isCancelled else {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||
@@ -870,8 +686,6 @@ final class SybilViewModel {
|
||||
selectedChat = ChatDetail(
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
parentChatId: chat.parentChatId,
|
||||
titleGenerationPending: chat.titleGenerationPending,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
@@ -952,15 +766,6 @@ final class SybilViewModel {
|
||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||
|
||||
// Search can open history outside the recent collection. Retain its
|
||||
// summary for provider settings, starring, and subsequent refreshes.
|
||||
if let item = sidebarSearchResults.first(where: {
|
||||
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
|
||||
}) {
|
||||
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
|
||||
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
|
||||
}
|
||||
|
||||
if draftKind == nil, selectedItem == selection {
|
||||
errorMessage = nil
|
||||
if case .search = selection {
|
||||
@@ -993,7 +798,7 @@ final class SybilViewModel {
|
||||
selectedChat = nil
|
||||
composerAttachments = []
|
||||
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
return nil
|
||||
@@ -1011,7 +816,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func selectAdjacentSidebarItem(offset: Int) {
|
||||
let items = visibleSidebarItems
|
||||
let items = sidebarItems
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
@@ -1031,7 +836,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func deleteItem(_ selection: SidebarSelection) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1044,11 +848,10 @@ final class SybilViewModel {
|
||||
try await client.deleteChat(chatID: chatID)
|
||||
case let .search(searchID):
|
||||
try await client.deleteSearch(searchID: searchID)
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
await refreshCollections(preferredSelection: nil)
|
||||
await refreshSidebarSearch()
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
||||
@@ -1057,36 +860,6 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
|
||||
guard isAuthenticated, !isForkingChat else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard messageID != nil || !isChatRowRunning(chatID) else {
|
||||
errorMessage = "Wait for the response to finish before forking this chat."
|
||||
return nil
|
||||
}
|
||||
|
||||
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
|
||||
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
|
||||
errorMessage = nil
|
||||
isForkingChat = true
|
||||
defer {
|
||||
isForkingChat = false
|
||||
}
|
||||
|
||||
do {
|
||||
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
|
||||
applyChatSummary(forkedChat, moveToFront: true)
|
||||
return forkedChat
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func renameChat(chatID: String, title: String) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
@@ -1104,7 +877,6 @@ final class SybilViewModel {
|
||||
do {
|
||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||
applyChatSummary(updated, moveToFront: true)
|
||||
await refreshSidebarSearch()
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||
@@ -1112,7 +884,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||
guard selection != .agent else { return }
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
@@ -1124,13 +895,12 @@ final class SybilViewModel {
|
||||
let client = try client()
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
let rootChatID = chatFamilyRootID(for: chatID)
|
||||
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||
applyChatSummary(updated, moveToFront: false)
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
applySearchSummary(updated, moveToFront: false)
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
@@ -1153,14 +923,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
isAppActive = false
|
||||
agent.setActive(false)
|
||||
appLifecycleGeneration += 1
|
||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
isAppActive = true
|
||||
agent.setActive(true)
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
@@ -1207,10 +975,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||
if isSidebarSearchActive {
|
||||
await refreshSidebarSearch()
|
||||
return
|
||||
}
|
||||
guard isAuthenticated, !isCheckingSession else {
|
||||
return
|
||||
}
|
||||
@@ -1366,7 +1130,6 @@ final class SybilViewModel {
|
||||
quickQuestionSubmittedPrompt = prompt
|
||||
quickQuestionSubmittedProvider = provider
|
||||
quickQuestionSubmittedModel = model
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
quickQuestionMessages = [
|
||||
Message(
|
||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||
@@ -1389,11 +1152,13 @@ final class SybilViewModel {
|
||||
let streamStatus = CompletionStreamStatus()
|
||||
|
||||
do {
|
||||
try await client().runQuickQuestionStream(
|
||||
body: QuickQuestionStreamRequest(
|
||||
try await client().runCompletionStream(
|
||||
body: CompletionStreamRequest(
|
||||
chatId: nil,
|
||||
persist: false,
|
||||
provider: provider,
|
||||
model: model,
|
||||
question: prompt
|
||||
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
||||
)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
@@ -1411,8 +1176,6 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
quickQuestionError = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||
}
|
||||
@@ -1427,16 +1190,17 @@ final class SybilViewModel {
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutateQuickQuestionAssistantMessage { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
|
||||
case let .done(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
mutateQuickQuestionAssistantMessage { _ in
|
||||
payload.text
|
||||
}
|
||||
|
||||
case let .error(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -1488,7 +1252,7 @@ final class SybilViewModel {
|
||||
|
||||
if let nextSelection {
|
||||
switch nextSelection {
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
case .chat, .search:
|
||||
@@ -1551,15 +1315,7 @@ final class SybilViewModel {
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
|
||||
var items = nextWorkspaceItems
|
||||
// A selected historical search result may be outside the latest 100.
|
||||
if let preferredSelection,
|
||||
let selectedSummary = workspaceItems.first(where: {
|
||||
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
|
||||
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
|
||||
items.append(selectedSummary)
|
||||
}
|
||||
applyWorkspaceItems(items)
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
@@ -1697,8 +1453,6 @@ final class SybilViewModel {
|
||||
|
||||
if selectedChat?.id == chat.id {
|
||||
selectedChat?.title = chat.title
|
||||
selectedChat?.parentChatId = chat.parentChatId
|
||||
selectedChat?.titleGenerationPending = chat.titleGenerationPending
|
||||
selectedChat?.updatedAt = chat.updatedAt
|
||||
selectedChat?.starred = chat.starred
|
||||
selectedChat?.starredAt = chat.starredAt
|
||||
@@ -1737,11 +1491,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
||||
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||
var updated = item
|
||||
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
|
||||
sidebarSearchResults[index] = updated
|
||||
}
|
||||
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||
workspaceItems.remove(at: existingIndex)
|
||||
if moveToFront {
|
||||
@@ -1755,22 +1504,6 @@ final class SybilViewModel {
|
||||
workspaceItems.insert(item, at: 0)
|
||||
}
|
||||
|
||||
private func chatFamilyRootID(for chatID: String) -> String {
|
||||
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||
return parentChatID
|
||||
}
|
||||
return chatID
|
||||
}
|
||||
|
||||
private func attachToVisibleActiveRunIfNeeded() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1799,7 +1532,7 @@ final class SybilViewModel {
|
||||
await self?.attachToActiveSearchStream(searchID: searchID)
|
||||
}
|
||||
|
||||
case .settings, .agent, nil:
|
||||
case .settings, nil:
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1851,28 +1584,21 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
pendingChatStates[chatID] = nil
|
||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
selectedChat = try await client.getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
}
|
||||
pendingChatStates[chatID] = nil
|
||||
} catch {
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
defer {
|
||||
pendingChatStates[chatID] = nil
|
||||
}
|
||||
pendingChatStates[chatID] = nil
|
||||
|
||||
if isCancellation(error) {
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
|
||||
if isActiveStreamNotFound(error) {
|
||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
@@ -1886,11 +1612,8 @@ final class SybilViewModel {
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
do {
|
||||
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
selectedChat = try await client().getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
} catch {
|
||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||
}
|
||||
@@ -2008,13 +1731,12 @@ final class SybilViewModel {
|
||||
return selectedChat?.id != chatID
|
||||
case let .search(searchID):
|
||||
return selectedSearch?.id != searchID
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSelectionIfNeeded() async {
|
||||
if selectedItem == .agent { isLoadingSelection = false; return }
|
||||
guard let target = selectedItem else {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -2029,16 +1751,13 @@ final class SybilViewModel {
|
||||
switch target {
|
||||
case let .chat(chatID):
|
||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||
let isSelectingDifferentChat = selectedChat?.id != chatID
|
||||
let chat = try await client.getChat(chatID: chatID)
|
||||
guard selectedItem == target, draftKind == nil else {
|
||||
return
|
||||
}
|
||||
selectedChat = chat
|
||||
selectedSearch = nil
|
||||
if isSelectingDifferentChat {
|
||||
requestChatBottomPin()
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
@@ -2056,7 +1775,7 @@ final class SybilViewModel {
|
||||
selectedSearch = search
|
||||
selectedChat = nil
|
||||
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
errorMessage = nil
|
||||
@@ -2111,13 +1830,6 @@ final class SybilViewModel {
|
||||
} else {
|
||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||
}
|
||||
defer {
|
||||
if let resolvedChatID = chatID {
|
||||
pendingChatStates[resolvedChatID] = nil
|
||||
} else {
|
||||
clearPendingChatState(for: sendContext)
|
||||
}
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if chatID == nil {
|
||||
@@ -2138,8 +1850,6 @@ final class SybilViewModel {
|
||||
selectedChat = ChatDetail(
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
parentChatId: created.parentChatId,
|
||||
titleGenerationPending: created.titleGenerationPending,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
@@ -2198,7 +1908,7 @@ final class SybilViewModel {
|
||||
let streamLifecycleGeneration = appLifecycleGeneration
|
||||
let streamStartedWhileInactive = !isAppActive
|
||||
|
||||
if shouldRequestChatTitle(baseChat) {
|
||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
@@ -2307,13 +2017,17 @@ final class SybilViewModel {
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
|
||||
case let .done(payload):
|
||||
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
||||
payload.text
|
||||
}
|
||||
|
||||
case let .error(payload):
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -2491,68 +2205,29 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
|
||||
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
||||
guard let index else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveDone(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
var message = pending.messages[index]
|
||||
message.content = transform(message.content)
|
||||
pending.messages[index] = message
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyPendingAssistantError(chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
||||
guard let index else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveError()
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) {
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &quickQuestionMessages,
|
||||
assistantMessagePrefix: "temp-assistant-quick-"
|
||||
)
|
||||
}
|
||||
|
||||
private func applyStreamingAssistantContentUpdate(
|
||||
_ update: StreamingAssistantContentUpdate,
|
||||
to messages: inout [Message],
|
||||
assistantMessagePrefix: String
|
||||
) {
|
||||
guard case let .replace(content) = update,
|
||||
let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) })
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].content = content
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
}
|
||||
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
@@ -2560,16 +2235,6 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
|
||||
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||
pendingChatStates[chatID] = pending
|
||||
@@ -2588,12 +2253,6 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
let update = quickQuestionStreamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
|
||||
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||
return
|
||||
@@ -2743,8 +2402,7 @@ final class SybilViewModel {
|
||||
|
||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||
switch context {
|
||||
case let .draftChat(identity):
|
||||
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||
case .draftChat:
|
||||
pendingDraftChatState = nil
|
||||
case let .chat(chatID):
|
||||
pendingChatStates[chatID] = nil
|
||||
@@ -2772,7 +2430,7 @@ final class SybilViewModel {
|
||||
return chats.contains(where: { $0.id == chatID })
|
||||
case let .search(searchID):
|
||||
return searches.contains(where: { $0.id == searchID })
|
||||
case .settings, .agent:
|
||||
case .settings:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2961,10 +2619,20 @@ final class SybilViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
||||
if chat.titleGenerationPending {
|
||||
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
||||
if let detail, detail.id == chatID {
|
||||
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
||||
|
||||
if let summary = chats.first(where: { $0.id == chatID }) {
|
||||
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ImageIO
|
||||
import Observation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
@@ -18,7 +19,6 @@ struct SybilWorkspaceView: View {
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@@ -155,6 +155,8 @@ struct SybilWorkspaceView: View {
|
||||
workspaceContentStack
|
||||
|
||||
if showsCustomWorkspaceNavigation {
|
||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
||||
.allowsHitTesting(false)
|
||||
customWorkspaceNavigationBar
|
||||
}
|
||||
}
|
||||
@@ -173,8 +175,6 @@ struct SybilWorkspaceView: View {
|
||||
if isSettingsSelected {
|
||||
SybilSettingsView(viewModel: viewModel)
|
||||
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.selectedItem == .agent {
|
||||
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||
} else if viewModel.isSearchMode {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.displayedSearch,
|
||||
@@ -193,12 +193,9 @@ struct SybilWorkspaceView: View {
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||
isForking: viewModel.isForkingChat,
|
||||
onForkResponse: forkResponse
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -232,28 +229,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func forkResponse(messageID: String) {
|
||||
guard viewModel.draftKind == nil,
|
||||
case let .chat(chatID) = viewModel.selectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
@@ -287,7 +262,7 @@ struct SybilWorkspaceView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
|
||||
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
|
||||
Text(selectedProviderModelSubtitle)
|
||||
.font(.sybil(size: 10, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
@@ -509,7 +484,7 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationTrailingControl: some View {
|
||||
if isSettingsSelected || viewModel.selectedItem == .agent {
|
||||
if isSettingsSelected {
|
||||
EmptyView()
|
||||
} else if viewModel.isSearchMode {
|
||||
searchModeNavigationLabel
|
||||
@@ -613,19 +588,46 @@ struct SybilWorkspaceView: View {
|
||||
.accessibilityLabel("Attach file")
|
||||
}
|
||||
|
||||
SybilPromptField(
|
||||
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||
TextField(
|
||||
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||
text: $viewModel.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($composerFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
submitComposer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
SybilPromptSendButton(
|
||||
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
|
||||
canSend: viewModel.canSendComposer,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
|
||||
Button {
|
||||
submitComposer()
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendComposer
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendComposer)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
@@ -1346,6 +1348,173 @@ private struct SybilNavigationFadeBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilWorkspaceCharacterBackdrop: View {
|
||||
var isBusy: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.13),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 10,
|
||||
endRadius: 150
|
||||
)
|
||||
.frame(width: 136, height: 118)
|
||||
.blur(radius: 7)
|
||||
.offset(x: 28, y: -24)
|
||||
|
||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
||||
.frame(width: 172, height: 172)
|
||||
.opacity(0.92)
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black,
|
||||
Color.black,
|
||||
Color.black.opacity(0)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.black.opacity(0.98),
|
||||
Color.black
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
.offset(x: 8, y: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
||||
var resourceName: String
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIImageView {
|
||||
let imageView = SybilAnimatedUIImageView()
|
||||
imageView.backgroundColor = .clear
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = false
|
||||
imageView.isOpaque = false
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
return imageView
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
||||
guard context.coordinator.resourceName != resourceName else {
|
||||
if !imageView.isAnimating {
|
||||
imageView.startAnimating()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
context.coordinator.resourceName = resourceName
|
||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
||||
imageView.startAnimating()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var resourceName: String?
|
||||
}
|
||||
}
|
||||
|
||||
private final class SybilAnimatedUIImageView: UIImageView {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum SybilAnimatedGIFCache {
|
||||
private static var images: [String: UIImage] = [:]
|
||||
|
||||
static func image(named name: String) -> UIImage? {
|
||||
if let cached = images[name] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let image = loadImage(named: name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
images[name] = image
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
private static func loadImage(named name: String) -> UIImage? {
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
||||
|
||||
guard let url,
|
||||
let data = try? Data(contentsOf: url),
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frameCount = CGImageSourceGetCount(source)
|
||||
guard frameCount > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var frames: [UIImage] = []
|
||||
frames.reserveCapacity(frameCount)
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
for index in 0 ..< frameCount {
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
||||
continue
|
||||
}
|
||||
frames.append(UIImage(cgImage: cgImage))
|
||||
duration += frameDuration(at: index, source: source)
|
||||
}
|
||||
|
||||
guard !frames.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if frames.count == 1 {
|
||||
return frames[0]
|
||||
}
|
||||
|
||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
||||
}
|
||||
|
||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
||||
else {
|
||||
return 0.08
|
||||
}
|
||||
|
||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
||||
return max(delay, 0.03)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
@testable import Sybil
|
||||
|
||||
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
|
||||
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
|
||||
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
|
||||
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
|
||||
private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = originalBytes, name: String = "original.bin") -> AgentAttachment {
|
||||
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
|
||||
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
|
||||
}
|
||||
private func descriptorData(capability: Bool = true) throws -> Data {
|
||||
var object: [String: Any] = ["id": "root", "title": "Sybil", "type": "persistent-agent", "model": "fixture", "protocol": "pi-room.v1", "websocket": "/v1/agent/ws",
|
||||
"activity": ["state": "idle", "isThinking": false, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": []]]
|
||||
if capability { object["capabilities"] = ["attachments": try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)), "future": true] }
|
||||
object["unknown"] = ["additive": true]
|
||||
return try JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
private final class AttachmentTestStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var rows: [AgentOutboxRow] = []
|
||||
var storage: AgentOutboxStorage { .init(read: { self.read() }, write: { value in self.lock.withLock { self.rows = value } }) }
|
||||
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
|
||||
}
|
||||
private actor AttachmentTestClient: AgentAPIClienting {
|
||||
var uploads: [String] = []
|
||||
var submissions: [AgentSubmission] = []
|
||||
var failUploads = 0
|
||||
var badReceipt = false
|
||||
var uploadDelay = Duration.milliseconds(20)
|
||||
var holdDownloads = false
|
||||
var downloadWaiters: [String: CheckedContinuation<Data, Never>] = [:]
|
||||
var downloadStarts: [String] = []
|
||||
var downloadActive = 0
|
||||
var downloadPeak = 0
|
||||
var beforeSubmit: (@Sendable () -> Bool)?
|
||||
var unavailableDescriptor = false
|
||||
var unavailableSocket = false
|
||||
func agentDescriptor() throws -> AgentDescriptor {
|
||||
if unavailableDescriptor { throw AgentAttachmentIssue(message: "Synthetic open proxy requires authentication", code: "attachments_unavailable", statusCode: 503) }
|
||||
return try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
|
||||
}
|
||||
func agentMessages(after: String?, before: String?) -> AgentPage { .init(conversationId: "root", messages: [], cursor: after ?? "0", hasMore: false) }
|
||||
func agentReadState() -> AgentReadState { .init(cursor: "0") }
|
||||
func agentMarkRead(cursor: String) -> AgentReadState { .init(cursor: cursor) }
|
||||
func agentSubmit(content: String, requestID: String) -> AgentReceipt { .init(id: 1, status: "queued", messageId: "inbound:sybil:\(requestID)") }
|
||||
func agentSubmit(_ submission: AgentSubmission) throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append(submission)
|
||||
return .init(id: 1, status: "queued", messageId: "inbound:sybil:\(submission.requestId)", attachmentIds: badReceipt ? nil : submission.attachmentIDs)
|
||||
}
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
uploads.append(uploadID)
|
||||
try await Task.sleep(for: uploadDelay)
|
||||
if failUploads > 0 { failUploads -= 1; throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507, details: ["futureQuotaField": .number(1)]) }
|
||||
return AgentAttachment(id: "server-\(uploadID)", kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
|
||||
}
|
||||
func agentDownload(_ attachment: AgentAttachment) async -> Data {
|
||||
downloadStarts.append(attachment.id); downloadActive += 1; downloadPeak = max(downloadPeak, downloadActive)
|
||||
defer { downloadActive -= 1 }
|
||||
if holdDownloads { return await withCheckedContinuation { downloadWaiters[attachment.id] = $0 } }
|
||||
return originalBytes
|
||||
}
|
||||
func pauseDownloads() { holdDownloads = true }
|
||||
func releaseDownload(_ id: String) { downloadWaiters.removeValue(forKey: id)?.resume(returning: originalBytes) }
|
||||
func downloadCounts() -> (Int, Int) { (downloadStarts.count, downloadPeak) }
|
||||
func downloadedIDs() -> [String] { downloadStarts }
|
||||
func agentSocket(after: String) throws -> any AgentSocket {
|
||||
if unavailableSocket { return AttachmentUnavailableSocket() }
|
||||
throw AgentFailure(message: "No socket in unit fixture")
|
||||
}
|
||||
func blockDescriptor() { unavailableDescriptor = true }
|
||||
func blockSocket() { unavailableSocket = true }
|
||||
func failNextUpload(delay: Duration = .milliseconds(20)) { failUploads = 1; uploadDelay = delay }
|
||||
func delayUploads(_ delay: Duration) { uploadDelay = delay }
|
||||
func malformedReceipts(_ value: Bool) { badReceipt = value }
|
||||
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
|
||||
func uploadIDs() -> [String] { uploads }
|
||||
func sent() -> [AgentSubmission] { submissions }
|
||||
}
|
||||
private struct AttachmentUnavailableSocket: AgentSocket {
|
||||
func receive() throws -> Data { throw AgentFailure(message: "Synthetic closed socket") }
|
||||
func send(_ text: String) {}
|
||||
func close() {}
|
||||
func closeCode() -> Int { 4503 }
|
||||
}
|
||||
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
||||
return false
|
||||
}
|
||||
private final class AttachmentURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
private static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, [String: String], Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, [String: String], Data)) { lock.withLock { handler = value } }
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let handler = Self.lock.withLock { Self.handler! }
|
||||
let (status, headers, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func attachmentSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [AttachmentURLProtocol.self]
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
private func requestBytes(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var data = Data(), buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(contentsOf: buffer.prefix(count)) }
|
||||
return data
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentAttachmentTests {
|
||||
@Test func additiveCapabilityAndDistinctMetadata() throws {
|
||||
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
|
||||
#expect(old.capabilities == nil)
|
||||
let current = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
|
||||
#expect(current.capabilities?.attachments?.supported == true)
|
||||
var future = try JSONSerialization.jsonObject(with: descriptorData()) as! [String: Any]
|
||||
future["capabilities"] = ["attachments": ["version": 9, "newWire": true]]
|
||||
let unsupported = try AgentCoding.decoder().decode(AgentDescriptor.self, from: JSONSerialization.data(withJSONObject: future))
|
||||
#expect(unsupported.capabilities?.attachments == nil)
|
||||
var other = attachmentLimits; other.version = 2
|
||||
#expect(!other.supported)
|
||||
let attachment = sampleAttachment()
|
||||
let message = Message(id: "user", createdAt: Date(), role: .user, content: "caption", metadata: .object(["agentAttachments": .array([attachment.metadataValue]), "future": .bool(true)]))
|
||||
#expect(message.attachments.isEmpty)
|
||||
#expect(message.agentAttachments == [attachment])
|
||||
#expect(message.content == "caption")
|
||||
let legacy = Data(#"{"id":"x","kind":"file","filename":"x.bin","mimeType":"application/octet-stream","sizeBytes":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","unknown":true}"#.utf8)
|
||||
#expect(try JSONDecoder().decode(AgentAttachment.self, from: legacy).workspacePath == nil)
|
||||
}
|
||||
@Test func safeNamesAndExactOriginalBytes() throws {
|
||||
#expect(try AgentAttachmentPreparation.filename("cafe\u{301}.txt") == "café.txt")
|
||||
for invalid in ["", ".", "..", "../secret", "x/y", "x\\y", "a\u{0000}b", "x\ny", String(repeating: "é", count: 128)] {
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.filename(invalid) }
|
||||
}
|
||||
let file = try AgentAttachmentPreparation.prepare(originalBytes, filename: "archive.zip", mimeType: "application/zip", limits: attachmentLimits)
|
||||
#expect(file.data == originalBytes)
|
||||
let text = try AgentAttachmentPreparation.prepare(originalBytes, filename: "invalid-utf8.txt", mimeType: "text/plain", limits: attachmentLimits)
|
||||
#expect(text.data == originalBytes) // Server classifies as file; client never decodes/truncates upload bytes.
|
||||
#expect(try AgentAttachmentPreparation.prepare(Data(), filename: "empty.txt", mimeType: "text/plain", limits: attachmentLimits).data.isEmpty)
|
||||
var small = attachmentLimits; small.maxFileBytes = 2
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.prepare(originalBytes, filename: "x.bin", mimeType: "application/octet-stream", limits: small) }
|
||||
}
|
||||
@Test @MainActor func rasterValidationAndFileSelection() throws {
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 8)).image { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 12, height: 8)) }
|
||||
let png = try #require(image.pngData())
|
||||
#expect(try AgentAttachmentPreparation.prepare(png, filename: "red.png", mimeType: "image/png", limits: attachmentLimits).data == png)
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/jpeg") }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/png", pixels: 20) }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(Data("<svg/>".utf8), mime: "image/svg+xml") }
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("sample.pdf"); try originalBytes.write(to: url)
|
||||
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
|
||||
#expect(file.data == originalBytes); #expect(file.mimeType == "application/pdf")
|
||||
}
|
||||
@Test @MainActor func syntheticHEICPhotoConvertsButFileKeepsOriginal() async throws {
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1800)).image { context in
|
||||
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1800))
|
||||
}
|
||||
let encoded = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(encoded, UTType.heic.identifier as CFString, 1, nil), "Simulator must provide an ImageIO HEIC encoder for this regression")
|
||||
CGImageDestinationAddImage(destination, try #require(image.cgImage), nil)
|
||||
#expect(CGImageDestinationFinalize(destination), "Synthetic HEIC encoding must succeed")
|
||||
let data = encoded as Data
|
||||
let prepared = try await AgentAttachmentPreparation.offMain {
|
||||
try AgentAttachmentPreparation.photo(data, filename: "cafe\u{301}.HEIC", mimeType: "image/heic", limits: attachmentLimits)
|
||||
}
|
||||
#expect(prepared.filename == "café.jpg"); #expect(prepared.mimeType == "image/jpeg")
|
||||
#expect(prepared.data.count <= attachmentLimits.imageLimit)
|
||||
let source = try #require(CGImageSourceCreateWithData(prepared.data as CFData, nil))
|
||||
#expect(CGImageSourceGetType(source) as String? == UTType.jpeg.identifier)
|
||||
let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
|
||||
#expect((properties[kCGImagePropertyPixelWidth] as? Int ?? Int.max) <= 2000)
|
||||
#expect((properties[kCGImagePropertyPixelHeight] as? Int ?? Int.max) <= 2000)
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let url = root.appendingPathComponent("original.heic"); try data.write(to: url)
|
||||
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
|
||||
#expect(file.data == data); #expect(file.filename == "original.heic"); #expect(file.mimeType == "image/heic")
|
||||
var tooSmall = attachmentLimits; tooSmall.maxImagePixels = 100
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.photo(data, filename: "large.heic", mimeType: "image/heic", limits: tooSmall) }
|
||||
}
|
||||
|
||||
@Test @MainActor func downloadCoordinatorBoundsAndCancelsQueuedTransfers() async throws {
|
||||
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
|
||||
await client.pauseDownloads()
|
||||
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
let three = Task { try await downloads.data(for: sampleAttachment("three"), client: client) }
|
||||
let cancelled = Task { try await downloads.data(for: sampleAttachment("cancelled"), client: client) }
|
||||
try await Task.sleep(for: .milliseconds(30)); cancelled.cancel()
|
||||
await #expect(throws: CancellationError.self) { try await cancelled.value }
|
||||
#expect(await client.downloadCounts().0 == 2)
|
||||
await client.releaseDownload("one"); _ = try await one.value
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 3 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
#expect(await client.downloadCounts().1 == 2)
|
||||
#expect(await client.downloadedIDs().contains("cancelled") == false)
|
||||
await client.releaseDownload("two"); await client.releaseDownload("three")
|
||||
_ = try await two.value; _ = try await three.value
|
||||
#expect(try await downloads.data(for: sampleAttachment("one"), client: client) == originalBytes)
|
||||
#expect(await client.downloadCounts().0 == 3) // Verified cache hit.
|
||||
}
|
||||
|
||||
@Test @MainActor func configurationResetRejectsOldDownloadsAndCache() async throws {
|
||||
let old = AttachmentTestClient(), next = AttachmentTestClient(), room = SybilAgentRoom()
|
||||
room.configure(client: old, storage: AttachmentTestStore().storage)
|
||||
defer { room.stop() }
|
||||
await old.pauseDownloads()
|
||||
let stale = Task { try await room.downloadAttachment(sampleAttachment()) }
|
||||
for _ in 0..<100 { if await old.downloadCounts().0 == 1 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
room.configure(client: next, storage: AttachmentTestStore().storage)
|
||||
await old.releaseDownload(sampleAttachment().id)
|
||||
await #expect(throws: CancellationError.self) { try await stale.value }
|
||||
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
|
||||
#expect(await next.downloadCounts().0 == 1)
|
||||
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
|
||||
#expect(await next.downloadCounts().0 == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func invalidatedDownloadQueueNeverStartsOrCaches() async throws {
|
||||
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
|
||||
await client.pauseDownloads()
|
||||
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
let queued = Task { try await downloads.data(for: sampleAttachment("queued"), client: client) }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
await downloads.invalidate()
|
||||
await #expect(throws: CancellationError.self) { try await queued.value }
|
||||
await client.releaseDownload("one"); await client.releaseDownload("two")
|
||||
await #expect(throws: CancellationError.self) { try await one.value }
|
||||
await #expect(throws: CancellationError.self) { try await two.value }
|
||||
await #expect(throws: CancellationError.self) { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
#expect(await client.downloadCounts().0 == 2)
|
||||
}
|
||||
|
||||
@Test func orderedReferenceAndReceiptValidation() throws {
|
||||
let a = sampleAttachment("one"), b = sampleAttachment("two")
|
||||
let request = try AgentSubmission(content: " ", requestID: "stable", attachments: [a, b])
|
||||
let fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any])
|
||||
#expect((fields["attachments"] as? [[String: String]]) == [["id": "one"], ["id": "two"]])
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable") }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable", attachments: [a, a]) }
|
||||
for ids: [String]? in [nil, [], ["one"], ["two", "one"], ["one", "two", "extra"]] {
|
||||
let receipt = AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ids)
|
||||
#expect(throws: AgentAttachmentIssue.self) { try receipt.validate(requestID: "stable", attachmentIDs: ["one", "two"]) }
|
||||
}
|
||||
try AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ["one", "two"]).validate(requestID: "stable", attachmentIDs: request.attachmentIDs)
|
||||
}
|
||||
@Test func multipartPreservesBinaryAndBoundary() throws {
|
||||
let id = UUID().uuidString
|
||||
let file = AgentPreparedAttachment(filename: "café \"original\".bin", mimeType: "application/octet-stream", data: originalBytes)
|
||||
let multipart = try AgentMultipart(file: file, uploadID: id)
|
||||
let prefix = Data("\r\n\r\n".utf8)
|
||||
#expect(multipart.body.range(of: originalBytes) != nil)
|
||||
let printable = String(decoding: multipart.body, as: UTF8.self)
|
||||
#expect(printable.components(separatedBy: "Content-Disposition:").count == 3)
|
||||
#expect(printable.contains("name=\"uploadId\"\r\n\r\n\(id)"))
|
||||
#expect(printable.contains("filename=\"café \\\"original\\\".bin\""))
|
||||
#expect(multipart.body.suffix(Data("\r\n--\(multipart.boundary)--\r\n".utf8).count) == Data("\r\n--\(multipart.boundary)--\r\n".utf8))
|
||||
#expect(multipart.body.range(of: prefix) != nil)
|
||||
}
|
||||
@Test func uploadTransportAuthBytesAndReceipt() async throws {
|
||||
let file = AgentPreparedAttachment(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), id = UUID().uuidString
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
#expect(request.url?.query == nil)
|
||||
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
|
||||
#expect(request.url!.path == "/api/v1/agent/attachments"); #expect(request.httpMethod == "POST")
|
||||
let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type"))
|
||||
let boundary = try #require(contentType.components(separatedBy: "boundary=").last)
|
||||
let bytes = requestBytes(request)
|
||||
#expect(bytes.starts(with: Data("--\(boundary)\r\n".utf8)))
|
||||
#expect(bytes.range(of: originalBytes) != nil); #expect(bytes.range(of: Data(id.utf8)) != nil)
|
||||
return (201, [:], try JSONEncoder().encode(["attachment": sampleAttachment()]))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
|
||||
#expect(try await client.agentUpload(file: file, uploadID: id) == sampleAttachment())
|
||||
}
|
||||
@Test func missingCapabilityNeverUploadsOrSubmitsReferences() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/api/v1/agent")
|
||||
return (200, [:], try descriptorData(capability: false))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "stable", attachments: [sampleAttachment()])) }
|
||||
}
|
||||
@Test func openPublicAPIDisablesAttachmentCapabilityAndDownloads() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/v1/agent")
|
||||
return (200, [:], try descriptorData())
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!), session: session)
|
||||
#expect(try await client.agentDescriptor().capabilities?.attachments == nil)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(sampleAttachment()) }
|
||||
}
|
||||
@Test func quotaCapabilitySnapshotAndDotFilenameAreAdditive() throws {
|
||||
var fields = try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)) as! [String: Any]
|
||||
fields.merge(["quotaBytes": 1073741824, "usedBytes": 20, "remainingBytes": 1073741804, "maxConcurrentUploads": 2, "maxConcurrentDecodes": 1, "future": true]) { _, next in next }
|
||||
let limits = try JSONDecoder().decode(AgentAttachmentCapabilities.self, from: JSONSerialization.data(withJSONObject: fields))
|
||||
#expect(limits.supported); #expect(limits.remainingBytes == 1073741804)
|
||||
#expect(limits.maxConcurrentUploads == 2); #expect(limits.maxConcurrentDecodes == 1)
|
||||
var attachment = sampleAttachment(name: ".notes.txt")
|
||||
attachment.workspacePath = "/data/workspace/uploads/server-id/_notes.txt"
|
||||
try attachment.validate()
|
||||
#expect(attachment.filename == ".notes.txt")
|
||||
#expect(attachment.workspacePath?.hasSuffix("/_notes.txt") == true)
|
||||
}
|
||||
@Test func downloadAuthOpaqueIDExactBytesAndLimits() async throws {
|
||||
let attachment = sampleAttachment("opaque/part?value#x")
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/opaque%2Fpart%3Fvalue%23x/content")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
return (200, ["Content-Length": String(originalBytes.count)], originalBytes)
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api/")!, authToken: "synthetic-test"), session: session)
|
||||
#expect(try await client.agentDownload(attachment) == originalBytes)
|
||||
AttachmentURLProtocol.install { _ in (200, [:], originalBytes + Data([9])) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 1, count: originalBytes.count)) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
}
|
||||
@Test func errorCodesAndAdditiveQuotaFieldsSurvive() async throws {
|
||||
for status in [409, 413, 422, 429, 507, 503] {
|
||||
AttachmentURLProtocol.install { request in
|
||||
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
|
||||
return (status, status == 429 ? ["Retry-After": "2"] : [:], try JSONSerialization.data(withJSONObject: ["error": true, "message": "Actionable storage message", "statusCode": status, "code": status == 507 ? "attachment_quota_exceeded" : status == 422 ? "image_model_unsupported" : "attachments_busy", "futureByteField": 123]))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!, authToken: "synthetic-test"), session: session)
|
||||
do { _ = try await client.agentDownload(sampleAttachment()); Issue.record("Expected failure") }
|
||||
catch let issue as AgentAttachmentIssue {
|
||||
#expect(issue.statusCode == status); #expect(issue.message == "Actionable storage message")
|
||||
#expect(issue.details["futureByteField"] == .number(123))
|
||||
if status == 507 { #expect(issue.code == "attachment_quota_exceeded") }
|
||||
if status == 429 { #expect(issue.retryAfter == "2") }
|
||||
if status == 422 { #expect(issue.code == "image_model_unsupported") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test func redirectPolicyRejectsUntrustedOrigin() async throws {
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let task = session.dataTask(with: URL(string: "https://fixture.invalid/api")!)
|
||||
let policy = AgentAttachmentRedirectPolicy()
|
||||
let response = HTTPURLResponse(url: URL(string: "https://fixture.invalid/api")!, statusCode: 302, httpVersion: nil, headerFields: [:])!
|
||||
await withCheckedContinuation { continuation in
|
||||
policy.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: URLRequest(url: URL(string: "https://untrusted.invalid")!)) { request in
|
||||
#expect(request == nil); continuation.resume()
|
||||
}
|
||||
}
|
||||
task.cancel()
|
||||
}
|
||||
@Test @MainActor func orderedSelectionFailureRetryRemoveAndCapability() async throws {
|
||||
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
|
||||
await client.failNextUpload()
|
||||
let first = try #require(draft.add(filename: "one.bin") { .init(filename: "one.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
let second = try #require(draft.add(filename: "two.bin") { .init(filename: "two.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(!draft.canSend)
|
||||
#expect(await attachmentEventually { draft.items[0].state == .failed && draft.items[1].state == .ready })
|
||||
#expect(draft.items[0].issue?.code == "attachment_quota_exceeded")
|
||||
#expect(!draft.canSend); draft.retry(first)
|
||||
#expect(await attachmentEventually { draft.items.allSatisfy { $0.state == .ready } })
|
||||
#expect(draft.ready.map(\.filename) == ["one.bin", "two.bin"])
|
||||
#expect(await client.uploadIDs() == [first, second, first])
|
||||
#expect(draft.canSend)
|
||||
draft.updateCapability(nil); #expect(!draft.canSend)
|
||||
#expect(draft.ready.count == 2)
|
||||
draft.remove(first); draft.remove(second); draft.caption = "Text still works"
|
||||
#expect(draft.canSend)
|
||||
}
|
||||
@Test @MainActor func removedInflightUploadCannotReappear() async throws {
|
||||
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
|
||||
await client.delayUploads(.milliseconds(250))
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
|
||||
let id = try #require(draft.add(filename: "remove.bin") { .init(filename: "remove.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(await attachmentEventually { draft.items.first?.state == .uploading })
|
||||
draft.remove(id)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(draft.items.isEmpty); #expect(!draft.canSend)
|
||||
}
|
||||
@Test @MainActor func stagedUploadAndReadyDraftSurviveRelaunch() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let storage = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
|
||||
let client = AttachmentTestClient(), first = AgentAttachmentDraft()
|
||||
await client.failNextUpload()
|
||||
first.configure(client: client, storage: storage); first.updateCapability(attachmentLimits); first.caption = "Keep my caption"
|
||||
let id = try #require(first.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(await attachmentEventually { first.items.first?.state == .failed })
|
||||
first.suspend()
|
||||
let next = AgentAttachmentDraft(); next.configure(client: client, storage: storage); next.updateCapability(attachmentLimits)
|
||||
#expect(next.caption == "Keep my caption"); #expect(next.items.first?.id == id)
|
||||
next.retry(id); #expect(await attachmentEventually { next.items.first?.state == .ready })
|
||||
#expect(await client.uploadIDs() == [id, id]); #expect(try storage.bytes(id) == originalBytes)
|
||||
let reopened = AgentAttachmentDraft(); reopened.configure(client: client, storage: storage)
|
||||
#expect(reopened.ready == next.ready); #expect(!reopened.canSend)
|
||||
reopened.updateCapability(attachmentLimits); #expect(reopened.canSend)
|
||||
reopened.clearAfterSubmission(); #expect(try storage.read().items.isEmpty)
|
||||
#expect(throws: (any Error).self) { try storage.bytes(id) }
|
||||
}
|
||||
@Test @MainActor func outboxReferencesPersistBeforePostRetryAndEchoWins() async throws {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
room.configure(client: client, storage: store.storage); defer { room.stop() }
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
await client.malformedReceipts(true)
|
||||
await client.requirePersisted { store.read().first?.attachments == [sampleAttachment()] }
|
||||
#expect(room.send("", attachments: [sampleAttachment()]))
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let original = try #require(room.outbox.first)
|
||||
room.retry(original.id)
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let submitted = await client.sent()
|
||||
#expect(submitted.count == 2); #expect(submitted.allSatisfy { $0.requestId == original.requestID && $0.attachmentIDs == [sampleAttachment().id] })
|
||||
var echo = original.message
|
||||
echo.content = "Attached: original.bin."
|
||||
echo.metadata?.self = .object(["kind": .string("message"), "entryId": .number(1), "status": .string("complete"), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
try room.receive(.messages(.init(conversationId: "root", messages: [echo], cursor: "1", hasMore: false)))
|
||||
room.flush()
|
||||
#expect(room.outbox.first?.confirmed == true); #expect(store.read().isEmpty)
|
||||
#expect(room.messages.count == 1); #expect(room.messages.first?.agentAttachments == [sampleAttachment()])
|
||||
#expect(room.messages.first?.createdAt == original.createdAt)
|
||||
}
|
||||
@Test @MainActor func failedDraftHandoffCannotPostOrCreateAnotherIdentityAfterRelaunch() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let disk = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
|
||||
let draftID = UUID().uuidString
|
||||
let item = AgentDraftItem(filename: "original.bin", state: .ready, attachment: sampleAttachment())
|
||||
try disk.write(.init(caption: "Keep caption and references", items: [item], id: draftID))
|
||||
var unavailable = disk
|
||||
unavailable.write = { snapshot in
|
||||
if snapshot.items.isEmpty { throw AgentAttachmentIssue.local("Synthetic disk failure") }
|
||||
try disk.write(snapshot)
|
||||
}
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
room.configure(client: client, storage: store.storage, draftStorage: unavailable)
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
#expect(!room.sendComposer())
|
||||
#expect(room.composer == "Keep caption and references")
|
||||
#expect(room.attachmentDraft.ready == [sampleAttachment()])
|
||||
#expect(store.read().count == 1)
|
||||
let saved = try #require(store.read().first)
|
||||
#expect(saved.draftID == draftID)
|
||||
room.retry(saved.id)
|
||||
#expect(await client.sent().isEmpty)
|
||||
room.stop()
|
||||
let reopened = SybilAgentRoom()
|
||||
reopened.configure(client: client, storage: store.storage, draftStorage: disk)
|
||||
defer { reopened.stop() }
|
||||
#expect(await attachmentEventually { !reopened.isLoading })
|
||||
#expect(reopened.composer.isEmpty); #expect(reopened.attachmentDraft.items.isEmpty)
|
||||
#expect(!reopened.sendComposer())
|
||||
reopened.retry(saved.id)
|
||||
#expect(await attachmentEventually { reopened.outbox.first?.seenOnServer == true })
|
||||
#expect(await client.sent().map(\.requestId) == [saved.requestID])
|
||||
#expect(await client.sent().first?.attachmentIDs == [sampleAttachment().id])
|
||||
}
|
||||
@Test @MainActor func openProxyHTTP503AndSocket4503KeepReadyDraftAndOutbox() async throws {
|
||||
for socketFailure in [false, true] {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
if socketFailure { await client.blockSocket() } else { await client.blockDescriptor() }
|
||||
let row = AgentOutboxRow(requestID: "unchanged-retry", content: "Retained caption", createdAt: Date(), attachments: [sampleAttachment()])
|
||||
try store.storage.write([row])
|
||||
let snapshot = AgentDraftSnapshot(caption: "Retained draft", items: [.init(filename: "original.bin", state: .ready, attachment: sampleAttachment())])
|
||||
let draft = AgentDraftStorage(read: { snapshot }, write: { _ in }, stage: { _, _ in }, bytes: { _ in originalBytes }, remove: { _ in })
|
||||
room.configure(client: client, storage: store.storage, draftStorage: draft)
|
||||
#expect(await attachmentEventually { room.error?.contains(socketFailure ? "4503" : "Synthetic open proxy") == true })
|
||||
#expect(!room.attachmentDraft.supportsAttachments)
|
||||
#expect(room.composer == "Retained draft"); #expect(room.attachmentDraft.ready == [sampleAttachment()])
|
||||
#expect(!room.sendComposer())
|
||||
#expect(room.outbox.first?.requestID == "unchanged-retry")
|
||||
#expect(store.read().first?.attachments == [sampleAttachment()])
|
||||
#expect(await client.sent().isEmpty)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func mismatchedCanonicalAndQueuedEchoesNeverHideLocalReferencesOrRetry() async throws {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
await client.malformedReceipts(true)
|
||||
room.configure(client: client, storage: store.storage); defer { room.stop() }
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
let attachments = [sampleAttachment("first"), sampleAttachment("second")]
|
||||
#expect(room.send("Original caption", attachments: attachments))
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let row = try #require(room.outbox.first)
|
||||
for (index, wrong) in [[], Array(attachments.reversed()), [attachments[0]]].enumerated() {
|
||||
var message = row.message
|
||||
message.content = "Incorrect server echo"
|
||||
message.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(Double(index + 1)), "agentAttachments": .array(wrong.map(\.metadataValue))])
|
||||
try room.receive(.messages(.init(conversationId: "root", messages: [message], cursor: String(index + 1), hasMore: false)))
|
||||
var activity = room.snapshot.room.activity
|
||||
activity.queuedMessages = [message]
|
||||
try room.receive(.activity("root", activity)); room.flush()
|
||||
#expect(room.outbox.first?.confirmed == false); #expect(room.outbox.first?.seenOnServer == false)
|
||||
#expect(room.outbox.first?.status == "failed"); #expect(room.outbox.first?.error != nil)
|
||||
#expect(room.messages.first?.agentAttachments == attachments)
|
||||
#expect(room.messages.first?.content == "Original caption")
|
||||
#expect(store.read().first?.requestID == row.requestID)
|
||||
}
|
||||
}
|
||||
@Test func legacyOutboxAndQueuedHistoryKeepDistinctAttachmentsAndHiddenTools() throws {
|
||||
let row = AgentOutboxRow(requestID: "stable", content: "caption", createdAt: Date(timeIntervalSince1970: 1))
|
||||
#expect(try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(row)).attachments == nil)
|
||||
var attached = row; attached.attachments = [sampleAttachment()]
|
||||
let restored = try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(attached))
|
||||
#expect(restored.attachments == attached.attachments); #expect(restored.requestID == row.requestID)
|
||||
var queued = restored.message; queued.metadata = .object(["kind": .string("message"), "status": .string("queued"), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
var committed = queued; committed.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(2), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
let tool = Message(id: "hidden", createdAt: Date(), role: .tool, content: "raw tool", metadata: .object(["kind": .string("tool_call"), "entryId": .number(3)]))
|
||||
var state = AgentRoomState()
|
||||
try state.apply(.init(conversationId: "root", messages: [queued, tool], cursor: "3", hasMore: false))
|
||||
try state.apply(.init(conversationId: "root", messages: [committed], cursor: "3", hasMore: false))
|
||||
try state.apply(.init(conversationId: "root", messages: [queued], cursor: "2", hasMore: false))
|
||||
#expect(state.messages.count == 2); #expect(state.cursor == "3"); #expect(state.unreadCount == 0)
|
||||
let transcript = AgentTranscript(messages: state.messages)
|
||||
#expect(transcript.messages.count == 1); #expect(transcript.messages[0].agentAttachments == [sampleAttachment()])
|
||||
#expect(transcript.messages[0].attachments.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
|
||||
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
|
||||
}
|
||||
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
|
||||
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
|
||||
}
|
||||
|
||||
private final class MemoryOutbox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var rows: [AgentOutboxRow] = []
|
||||
var storage: AgentOutboxStorage {
|
||||
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
|
||||
}
|
||||
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
|
||||
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
|
||||
}
|
||||
private actor ControlledAgentSocket: AgentSocket {
|
||||
let stream: AsyncThrowingStream<Data, any Error>
|
||||
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
|
||||
private(set) var closed = false
|
||||
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
|
||||
func receive() async throws -> Data {
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
guard let data = try await iterator.next() else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
func send(_ text: String) {}
|
||||
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
|
||||
func closeCode() -> Int { 0 }
|
||||
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
|
||||
}
|
||||
private actor TestAgentClient: AgentAPIClienting {
|
||||
var pages: [String: AgentPage]
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
var socketAfters: [String] = []
|
||||
var sockets: [ControlledAgentSocket] = []
|
||||
var holdRead = false
|
||||
var heldRead: CheckedContinuation<AgentReadState, Never>?
|
||||
var beforeSubmit: (@Sendable () -> Bool)?
|
||||
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
|
||||
func agentDescriptor() -> AgentDescriptor {
|
||||
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
|
||||
}
|
||||
func agentMessages(after: String?, before: String?) -> AgentPage {
|
||||
let cursor = after ?? "0"; requests.append(cursor)
|
||||
return pages[cursor] ?? agentPage([], cursor: cursor)
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
}
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
|
||||
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
|
||||
}
|
||||
func agentSocket(after: String) async -> any AgentSocket {
|
||||
socketAfters.append(after)
|
||||
let socket = ControlledAgentSocket(); sockets.append(socket)
|
||||
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
|
||||
return socket
|
||||
}
|
||||
func connectedAfters() -> [String] { socketAfters }
|
||||
func latestSocket() -> ControlledAgentSocket? { sockets.last }
|
||||
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
|
||||
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
|
||||
func submittedIDs() -> [String] { submissions.map(\.1) }
|
||||
func requestedCursors() -> [String] { requests }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func eventually(_ predicate: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 {
|
||||
if predicate() { return true }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
struct AgentTests {
|
||||
@Test @MainActor func mixedLoadedHistoryFiltersOnlyToolRows() async throws {
|
||||
var user = agentMessage("user", entry: "1", role: .user)
|
||||
user.content = "Explain the tool_call and tool result."
|
||||
let call = agentMessage("call", entry: "2", role: .tool, info: ["status": .string("initiated")])
|
||||
var reply = agentMessage("reply", entry: "3")
|
||||
reply.content = "The search tool failed; here is what you can do."
|
||||
// Role-only results still need hiding even without tool-call metadata.
|
||||
var result = agentMessage("result", entry: "4", role: .tool)
|
||||
result.metadata = nil
|
||||
let markedCall = agentMessage("marked-call", entry: "5", info: ["kind": .string("tool_call")])
|
||||
var plainReply = agentMessage("plain-reply", entry: "6")
|
||||
plainReply.metadata = nil
|
||||
let failure = agentMessage("failure", entry: "7", info: ["status": .string("failed"), "error": .string("Please try again")])
|
||||
let terminal = agentMessage("terminal", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("Raw tool failure"), "resultPreview": .string("Raw result")])
|
||||
let history = [user, call, reply, result, markedCall, plainReply, failure, terminal].enumerated().map { index, message in
|
||||
var message = message
|
||||
message.createdAt.addTimeInterval(Double(index))
|
||||
return message
|
||||
}
|
||||
let client = TestAgentClient(pages: ["0": agentPage(Array(history.dropLast()), cursor: "7", more: true), "7": agentPage([history[7]], cursor: "8")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(await client.requestedCursors() == ["0", "7"])
|
||||
#expect(await client.connectedAfters() == ["8"])
|
||||
let transcript = AgentTranscript(messages: room.messages)
|
||||
#expect(transcript.displayed == [history[0], history[2], history[5], history[6]])
|
||||
#expect(room.snapshot.room.messages == history)
|
||||
#expect(room.messages.count == 8)
|
||||
#expect(room.snapshot.room.cursor == "8")
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
#expect(room.unreadCount == 2)
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
// Shared regular-chat tool metadata remains available and unchanged.
|
||||
#expect(terminal.isToolCallLog)
|
||||
#expect(terminal.toolCallMetadata?.resultPreview == "Raw result")
|
||||
}
|
||||
|
||||
@Test func transcriptWindowsAndAnchorsUseVisibleRows() {
|
||||
var raw: [Message] = [], visible: [Message] = []
|
||||
for index in 1...160 {
|
||||
var message = agentMessage("reply-\(index)", entry: "\(index * 2)")
|
||||
message.createdAt.addTimeInterval(Double(index * 2) * 86_400)
|
||||
var tool = agentMessage("tool-\(index)", entry: "\(index * 2 + 1)", role: .tool)
|
||||
// Tools on separate dates must not introduce their own separators.
|
||||
tool.createdAt = message.createdAt.addingTimeInterval(86_400)
|
||||
raw += [message, tool]; visible.append(message)
|
||||
}
|
||||
let initial = AgentTranscript(messages: raw)
|
||||
#expect(initial.startIndex == 60)
|
||||
#expect(initial.displayed == Array(visible.suffix(100)))
|
||||
let anchorID = initial.displayed.first?.id
|
||||
let hidden = agentMessage("late-tool", entry: "400", role: .tool)
|
||||
let updated = AgentTranscript(messages: raw + [hidden], startID: anchorID)
|
||||
#expect(updated.displayed == initial.displayed)
|
||||
let earlierID = initial.messages[max(0, initial.startIndex - 100)].id
|
||||
let expanded = AgentTranscript(messages: raw, startID: earlierID, limit: 200)
|
||||
#expect(expanded.displayed == visible)
|
||||
#expect(expanded.displayed.firstIndex(where: { $0.id == anchorID }) == 60)
|
||||
let hiddenAnchor = AgentTranscript(messages: raw, startID: "tool-160")
|
||||
#expect(hiddenAnchor.displayed == initial.displayed)
|
||||
let onlyTools = AgentTranscript(messages: raw.filter { $0.role == .tool })
|
||||
#expect(onlyTools.messages.isEmpty)
|
||||
#expect(onlyTools.displayed.isEmpty)
|
||||
#expect(onlyTools.startIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func liveToolUpsertsPreserveRawHistoryCursorsAndActivity() async throws {
|
||||
let user = agentMessage("user", entry: "1", role: .user)
|
||||
let reply = agentMessage("reply", entry: "2")
|
||||
let client = TestAgentClient(pages: ["0": agentPage([user, reply], cursor: "2")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let queued = agentMessage("queued", entry: "0", role: .user, info: ["status": .string("queued")])
|
||||
var activity = AgentActivity()
|
||||
activity.isThinking = true; activity.backgroundWorkers = 2; activity.queued = 1
|
||||
activity.queuedMessages = [queued]
|
||||
activity.wait = .init(due: 1_800_000_000_000, reason: "Waiting for background work")
|
||||
activity.error = "A worker needs attention"
|
||||
try room.receive(.activity("root", activity)); room.flush()
|
||||
#expect(await eventually { room.snapshot.isTyping })
|
||||
let visible = AgentTranscript(messages: room.messages).displayed
|
||||
#expect(Set(visible.map(\.id)) == ["user", "reply", "queued"])
|
||||
let start = agentMessage("tool", entry: "3", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
|
||||
let completed = agentMessage("tool", entry: "4", role: .tool, info: ["status": .string("completed"), "resultPreview": .string("Raw preview")])
|
||||
let result = agentMessage("tool", entry: "5", role: .tool, info: ["status": .string("completed"), "result": .object(["matches": .number(3)])])
|
||||
for (message, cursor) in [(start, "3"), (completed, "4"), (result, "5"), (start, "3")] {
|
||||
try room.receive(.messages(agentPage([message], cursor: cursor))); room.flush()
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible)
|
||||
#expect(room.snapshot.room.messages.count == 3)
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
#expect(room.snapshot.isTyping)
|
||||
#expect(room.snapshot.room.activity == activity)
|
||||
}
|
||||
let tool = try #require(room.snapshot.room.messages.first(where: { $0.id == "tool" })?.toolCallMetadata)
|
||||
#expect(tool.status == "completed")
|
||||
#expect(tool.args == .object(["query": .string("fixture")]))
|
||||
#expect(tool.resultPreview == "Raw preview")
|
||||
#expect(tool.result == .object(["matches": .number(3)]))
|
||||
#expect(tool.startedAt == "start")
|
||||
#expect(room.snapshot.room.cursor == "5")
|
||||
// The rendered snapshot's cursor includes hidden tool updates. A later
|
||||
// reply must remain unread when only that captured cursor is acknowledged.
|
||||
room.isSelected = true
|
||||
let capturedCursor = room.snapshot.room.cursor
|
||||
let lateReply = agentMessage("late-reply", entry: "6")
|
||||
let lateTool = agentMessage("late-tool", entry: "7", role: .tool)
|
||||
try room.receive(.messages(agentPage([lateReply, lateTool], cursor: "7"))); room.flush()
|
||||
room.markRead(renderedCursor: capturedCursor)
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "5" })
|
||||
#expect(room.snapshot.room.cursor == "7")
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
|
||||
#expect(!room.snapshot.isTyping)
|
||||
#expect(room.isBusy)
|
||||
let socket = try #require(await client.latestSocket())
|
||||
await socket.push(#"{"type":"error","error":"Room connection unavailable"}"#)
|
||||
#expect(await eventually { room.error == "Room connection unavailable" })
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
|
||||
#expect(room.snapshot.room.messages.count == 5)
|
||||
}
|
||||
|
||||
@Test @MainActor func failedSendDetailsAndRetryStayVisibleWithHiddenTools() async throws {
|
||||
let tool = agentMessage("tool", entry: "1", role: .tool)
|
||||
let client = TestAgentClient(pages: ["0": agentPage([tool], cursor: "1")])
|
||||
await client.setFailures(submit: true)
|
||||
let memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(room.send("Please retry the search tool"))
|
||||
#expect(await eventually { room.outbox.first?.status == "failed" })
|
||||
let failed = try #require(AgentTranscript(messages: room.messages).displayed.first)
|
||||
let requestID = try #require(room.outbox.first?.requestID)
|
||||
#expect(failed.role == .user)
|
||||
#expect(failed.content == "Please retry the search tool")
|
||||
#expect(failed.metadata?.objectValue?["status"] == .string("failed"))
|
||||
#expect(failed.metadata?.objectValue?["error"] == .string("Lost acknowledgement"))
|
||||
#expect(AgentTranscript(messages: room.messages).displayed.count == 1)
|
||||
#expect(memory.read().first?.requestID == requestID)
|
||||
await client.setFailures()
|
||||
room.retry(failed.id)
|
||||
#expect(await eventually { room.outbox.first?.status == "queued" })
|
||||
let retried = try #require(AgentTranscript(messages: room.messages).displayed.first)
|
||||
#expect(retried.id == failed.id)
|
||||
#expect(retried.createdAt == failed.createdAt)
|
||||
#expect(retried.metadata?.objectValue?["status"] == .string("queued"))
|
||||
#expect(retried.metadata?.objectValue?["error"] == nil)
|
||||
#expect(await client.submittedIDs() == [requestID, requestID])
|
||||
#expect(room.snapshot.room.messages == [tool])
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test func cursorAndMalformedFrames() throws {
|
||||
#expect(try AgentCursor.normalize("00012") == "12")
|
||||
#expect(try AgentCursor.advance("9", "10") == "10")
|
||||
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
|
||||
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
|
||||
let original = state
|
||||
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
|
||||
#expect(state == original)
|
||||
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
|
||||
#expect(state.cursor == "10")
|
||||
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
|
||||
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
|
||||
}
|
||||
|
||||
@Test func terminalToolAndRestSocketRace() throws {
|
||||
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
|
||||
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
|
||||
end.createdAt.addTimeInterval(5)
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
|
||||
try state.apply(agentPage([start], cursor: "5"))
|
||||
#expect(state.cursor == "9")
|
||||
#expect(state.messages.count == 2)
|
||||
let tool = try #require(state.messages.first?.toolCallMetadata)
|
||||
#expect(tool.status == "failed")
|
||||
#expect(tool.error == "fixture failure")
|
||||
#expect(tool.args == .object(["query": .string("fixture")]))
|
||||
#expect(tool.startedAt == "start")
|
||||
#expect(state.messages.first?.createdAt == start.createdAt)
|
||||
}
|
||||
|
||||
@Test func unreadExcludesToolsAndUsers() throws {
|
||||
var state = AgentRoomState()
|
||||
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
|
||||
state.readCursor = "9"
|
||||
#expect(state.unreadCount == 1)
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
|
||||
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
|
||||
#expect(state.unreadCount == 0)
|
||||
}
|
||||
|
||||
@Test func typingReplyAndStaleActivityAreAtomic() throws {
|
||||
var presentation = AgentPresentation(), state = AgentRoomState()
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
|
||||
var after = state; after.activity = busy
|
||||
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
|
||||
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
|
||||
try after.apply(reply)
|
||||
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
|
||||
#expect(state.activity.isBusy)
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
|
||||
for name in AgentPresentation.quietTools {
|
||||
busy.tools.append(.init(id: name, name: name, status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
|
||||
}
|
||||
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
|
||||
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
||||
}
|
||||
|
||||
@Test func socketURLAndAuthentication() throws {
|
||||
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
||||
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
|
||||
#expect(frame.objectValue?["token"] == .string("token"))
|
||||
#expect(frame.objectValue?["after"] == .string("9"))
|
||||
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
|
||||
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
|
||||
#expect(openFrame.objectValue?["token"] == .null)
|
||||
}
|
||||
|
||||
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(await client.requestedCursors() == ["0", "3", "7"])
|
||||
#expect(room.snapshot.room.cursor == "8")
|
||||
#expect(room.unreadCount == 1)
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs == ["late"])
|
||||
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
|
||||
room.flush()
|
||||
#expect(room.snapshot.entranceIDs.isEmpty)
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
|
||||
let memory = MemoryOutbox(), client = TestAgentClient()
|
||||
await client.setFailures(submit: true)
|
||||
await client.requirePersisted { !memory.read().isEmpty }
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
#expect(room.send("identical")); #expect(room.send("identical"))
|
||||
#expect(room.messages.count == 2)
|
||||
let first = room.outbox[0], second = room.outbox[1]
|
||||
#expect(first.requestID != second.requestID)
|
||||
#expect(memory.read().count == 2)
|
||||
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
|
||||
room.flush()
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
#expect(room.outbox[0].status == "complete")
|
||||
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
|
||||
room.retry(second.id)
|
||||
#expect(await eventually { room.outbox[1].status == "failed" })
|
||||
let ids = await client.submittedIDs()
|
||||
#expect(ids.filter { $0 == second.requestID }.count == 2)
|
||||
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
|
||||
defer { restored.stop() }
|
||||
#expect(restored.outbox.count == 1)
|
||||
#expect(restored.outbox[0].requestID == second.requestID)
|
||||
#expect(restored.outbox[0].createdAt == second.createdAt)
|
||||
}
|
||||
|
||||
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
|
||||
let client = TestAgentClient(); await client.setFailures(malformed: true)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.send("fixture")
|
||||
#expect(await eventually { room.outbox.first?.status == "failed" })
|
||||
#expect(room.outbox.first?.seenOnServer == false)
|
||||
let failed = SybilAgentRoom()
|
||||
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
|
||||
defer { failed.stop() }
|
||||
failed.send("must not submit")
|
||||
#expect(failed.outbox.first?.status == "failed")
|
||||
#expect(await client.submittedIDs().count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { !room.isLoading })
|
||||
room.isSelected = true
|
||||
await client.setFailures(read: true)
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.error != nil })
|
||||
#expect(room.snapshot.room.readCursor == "0")
|
||||
await client.setFailures()
|
||||
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
#expect(room.unreadCount == 1)
|
||||
room.isObscured = true; room.markRead(renderedCursor: "2")
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
#expect(room.snapshot.room.readCursor == "1")
|
||||
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
|
||||
#expect(room.unreadCount == 0)
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.send("interrupted fixture")
|
||||
let original = try #require(room.outbox.first)
|
||||
room.setActive(false)
|
||||
#expect(room.outbox.first?.status == "failed")
|
||||
#expect(memory.read().first?.requestID == original.requestID)
|
||||
room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.retry(original.id)
|
||||
#expect(await eventually { room.outbox.first?.seenOnServer == true })
|
||||
#expect(room.outbox.first?.createdAt == original.createdAt)
|
||||
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
|
||||
}
|
||||
|
||||
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
|
||||
let old = TestAgentClient(); await old.pauseRead()
|
||||
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
|
||||
for _ in 0..<100 {
|
||||
if await old.hasHeldRead() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
room.disconnect()
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
|
||||
room.configure(client: next, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
await old.releaseRead()
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
try await room.catchUp(client: next)
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
#expect(await next.requestedCursors() == ["0", "2"])
|
||||
#expect(await old.connectedAfters().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
|
||||
let client = TestAgentClient()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let socket = try #require(await client.latestSocket())
|
||||
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
|
||||
await socket.push(frame)
|
||||
#expect(await eventually { room.snapshot.room.cursor == "7" })
|
||||
room.setActive(false); room.setActive(true)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
let next = try #require(await client.latestSocket())
|
||||
await next.push(frame)
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
#expect(await client.connectedAfters() == ["0", "7"])
|
||||
#expect(room.messages.count == 1)
|
||||
#expect(await socket.closed)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-223
@@ -1,223 +0,0 @@
|
||||
# Sybil for iOS and Mac Catalyst
|
||||
|
||||
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
||||
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
|
||||
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
||||
release/archive signing settings are unchanged.
|
||||
|
||||
The conversation sidebar includes **Search chats and web searches** on iPhone,
|
||||
iPad, and Mac. It searches all saved history by title, conversation text, and saved
|
||||
web-search query/answer, with single-line excerpts that bold matching words and
|
||||
truncate in the middle. Search waits briefly while typing,
|
||||
cancels superseded requests, and leaves the current conversation open. Clear the
|
||||
field to return to recent items. Searches use word prefixes (for example, `plan`
|
||||
finds `planning`); if more than 100 items match, refine the query.
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
The permanent **Sybil Agent** sidebar item opens the persistent room on both
|
||||
phone and iPad. It uses the configured Sybil API URL (including an explicit
|
||||
`/api` path) and token. The room stays subscribed while other chats are selected;
|
||||
foreground and network recovery replay from the last merged cursor. Shared unread
|
||||
counts include only public assistant replies and are acknowledged after the
|
||||
visible transcript reaches the bottom and its entrance animation finishes.
|
||||
|
||||
Multiple messages can be queued while the Agent works. The local outbox is saved
|
||||
atomically under Application Support before submission and is scoped to the API
|
||||
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
|
||||
its request ID and timestamp across relaunch. A canonical server echo confirms it
|
||||
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
|
||||
in the Agent transcript, including loaded history and live updates. History windows,
|
||||
date separators and scroll anchors use visible messages; raw merged history and
|
||||
cursors remain intact. Send failures, room errors and activity indicators remain
|
||||
visible. Model selection and tool cards continue to belong to ordinary chats.
|
||||
|
||||
Agent contracts remain in `../docs/api/rest.md` and
|
||||
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
|
||||
contract (2026-10-14), now documented in the integrated API sources above.
|
||||
Attachment readiness requires the coordinated backend/runtime and existing public
|
||||
API bearer authentication. This iOS change does not deploy or configure them.
|
||||
|
||||
### ROOT attachments
|
||||
|
||||
The Agent composer exposes Files and Photo Library only when the authenticated
|
||||
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
|
||||
malformed, or unsupported capability keeps the client text-only. Ordinary chat
|
||||
attachments retain their existing inline format and behavior.
|
||||
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
|
||||
retaining draft descriptors and outbox retry identities; reconnect checks support.
|
||||
|
||||
Files preserve their original bytes, including PDF, archives, text and HEIC files.
|
||||
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
|
||||
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
|
||||
raster images; model input is static, with no claim of document or animation
|
||||
understanding. Safe original names supplied by the picker are retained; PhotosPicker
|
||||
may provide an export filename rather than the original Photo Library asset name.
|
||||
The production app does not request broad Photo Library access.
|
||||
|
||||
Selection order is stable. One preparation/upload runs at a time, with per-item
|
||||
loading, upload, error, retry and removal. Sending requires every item to be ready;
|
||||
an empty caption can send ready attachments. Advertised limits are checked against
|
||||
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
|
||||
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
|
||||
messages/codes, quota details and Retry-After survive failures, including 409, 413,
|
||||
429 and 507. Removal cancels local work and removes the reference; it does not
|
||||
delete an uploaded server original.
|
||||
|
||||
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
|
||||
manifest under Application Support, scoped to the API base URL. Prepared bytes are
|
||||
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
|
||||
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
|
||||
for interrupted staged uploads with the same upload UUID. A selection interrupted
|
||||
before staging must be removed and selected again. Uploads suspend when the room
|
||||
stops; failed work does not automatically retry. Uploaded originals have no client
|
||||
expiration. After submission, the outbox owns the caption, ordered descriptors and
|
||||
stable request ID. A persisted draft identity recovers a crash during this handoff
|
||||
without creating a second send. Storage failure blocks submission rather than
|
||||
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
|
||||
in order; matching canonical echoes still win ambiguous HTTP failures.
|
||||
|
||||
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
|
||||
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
|
||||
which can be copied. The client never constructs a runtime path or sends one as a
|
||||
reference; leading-dot display filenames remain unchanged. Captions remain public
|
||||
text, including the server's fallback for attachment-only messages.
|
||||
|
||||
Original downloads use the configured authenticated URLSession and API base
|
||||
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
|
||||
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
|
||||
change. Queued/active cancellation and generation checks prevent stale previews
|
||||
from crossing configurations. Raster previews use bounded thumbnails; text previews
|
||||
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
|
||||
temporary file removed when its card disappears or configuration changes. An app
|
||||
crash may leave a temporary export for normal OS temporary-directory cleanup.
|
||||
Image preview frames have fixed height to preserve scroll anchors during loading.
|
||||
Attachment modals track their own visibility without changing Quick Question state.
|
||||
|
||||
### 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 attachment capability/metadata, multipart/auth/bytes/errors,
|
||||
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
|
||||
relaunch, cancellation, two-transfer concurrency and configuration isolation 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.
|
||||
Attachment cases exercise authenticated originals, safe text/raster display,
|
||||
history reload, capability absence and picker cancellation. The real Photo Library
|
||||
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
|
||||
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
|
||||
Selection-to-send remains a manual/device pre-release check, not a validated UI
|
||||
path. The gate prevents photo mutations during the general UI suite. Synthetic
|
||||
HEIC conversion and original HEIC file preservation are covered by package tests.
|
||||
The fixture accepts multipart uploads, stable upload/message retries, structured
|
||||
errors and attachment receipts; it is not a runtime image-decoder or quota test.
|
||||
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
|
||||
Simulator Reduce Motion enabled before release.
|
||||
|
||||
## Quick Question on macOS
|
||||
|
||||
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
||||
Question panel, including over other applications. The prompt is focused as soon
|
||||
as it opens, with any existing prompt selected so typing replaces it. The same
|
||||
select-on-open behavior applies to Quick Question on iPhone and iPad.
|
||||
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
|
||||
second **Option+Space**, the close button, or clicking outside dismisses it.
|
||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||
**Open in Chat** saves the question and answer in the regular workspace.
|
||||
|
||||
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||
Question, while the normal chat/search composer continues to use Return for a
|
||||
new line.
|
||||
|
||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||
build opts out of automatic termination, and the native shortcut service holds a
|
||||
matching automatic-termination activity until it stops. No hidden keep-alive
|
||||
window or sleep-preventing activity is used. Mac scene support also allows the
|
||||
workspace to reopen after its last window is closed.
|
||||
|
||||
When Catalyst is backgrounded, opening Quick Question first requests app
|
||||
activation and waits for UIKit to enter the foreground before creating or ordering
|
||||
the native panel. Presentation is deferred out of the hotkey event callback.
|
||||
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
|
||||
that required activation returns focus to the previous app; explicitly opening
|
||||
the workspace keeps focus in Sybil.
|
||||
|
||||
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
|
||||
workspace window when none are open. **Command+Shift+N** always opens a new window,
|
||||
including while signed out. **Command+Option+N** starts a new search; iOS retains
|
||||
its existing Command+Shift+N search shortcut.
|
||||
|
||||
The shortcut is also shown under **File → Quick Question**. If another application
|
||||
already owns Option+Space, the panel reports that the shortcut is unavailable;
|
||||
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
|
||||
needed for the registered hotkey.
|
||||
|
||||
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
|
||||
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
|
||||
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
|
||||
provider/model preferences, streaming, and conversion to chat remain in the
|
||||
existing `SybilViewModel`; there is no separate backend or API contract. The panel
|
||||
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
|
||||
Catalyst process.
|
||||
|
||||
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
|
||||
action, and does not build or embed the macOS helper.
|
||||
|
||||
## Verification
|
||||
|
||||
- `just test`: existing Swift package tests on the iPhone simulator.
|
||||
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
|
||||
- For a manual Mac check, open the panel from another app, type immediately,
|
||||
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
|
||||
returns input to the previous app. Check a second display and a full-screen Space
|
||||
when available.
|
||||
- Close every Mac window, invoke Option+Space, and verify the saved question and
|
||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||
it. On iOS, verify Return submits Quick Question while Return in the normal
|
||||
prompt adds a newline. In the Mac workspace, verify Return submits and
|
||||
Shift+Return adds a newline.
|
||||
- Check Command+N both with a workspace open and after closing every workspace.
|
||||
Command+Shift+N must open another window even when one already exists, and
|
||||
Command+Option+N must start a search without opening a window.
|
||||
- With all workspace windows closed, switch to another app and repeatedly open
|
||||
and dismiss Quick Question, including two quick Option+Space presses while it
|
||||
is opening. Check that no late panel appears after cancellation and that no
|
||||
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
|
||||
@@ -1,293 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only deterministic Agent contract fixture. Never contacts external services.
|
||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import unicodedata
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
from aiohttp import web
|
||||
|
||||
sockets = set()
|
||||
messages = []
|
||||
requests = {}
|
||||
read_cursor = 0
|
||||
cursor = 0
|
||||
busy = False
|
||||
attempts = {}
|
||||
log = []
|
||||
reply_tasks = set()
|
||||
attachments = {}
|
||||
uploads = {}
|
||||
attachment_capability = True
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
|
||||
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
|
||||
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
|
||||
|
||||
def attachment_error(status, code, message, **fields):
|
||||
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
|
||||
headers={"Retry-After": "2"} if status == 429 else None)
|
||||
|
||||
|
||||
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, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
|
||||
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
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'transcript-comparison':
|
||||
transcript_comparison = True
|
||||
elif action == 'capability':
|
||||
attachment_capability = body.get('enabled', True)
|
||||
elif action == 'upload-error':
|
||||
next_upload_error = body
|
||||
elif action == 'blocked-proxy':
|
||||
blocked_proxy = body.get('enabled', True)
|
||||
elif action == 'attachment-history':
|
||||
selected = [attachments[key]['descriptor'] for key in body['ids']]
|
||||
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
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'))
|
||||
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
|
||||
if path == '/api/v1/workspace-items':
|
||||
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
|
||||
if path == '/api/v1/chats':
|
||||
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
|
||||
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
|
||||
return web.json_response(dict(chat=dict(comparison, messages=messages)))
|
||||
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 blocked_proxy:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
|
||||
if path == '/api/v1/agent':
|
||||
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/attachments' and request.method == 'POST':
|
||||
if not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
if next_upload_error:
|
||||
failure = next_upload_error; next_upload_error = None
|
||||
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
|
||||
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
|
||||
reader = await request.multipart()
|
||||
parts = {}
|
||||
while part := await reader.next():
|
||||
if part.name not in ('file', 'uploadId') or part.name in parts:
|
||||
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
|
||||
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
|
||||
if set(parts) != {'file', 'uploadId'}:
|
||||
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
|
||||
try:
|
||||
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
|
||||
filename = unicodedata.normalize('NFC', parts['file'][0])
|
||||
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
|
||||
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
|
||||
except (ValueError, AssertionError, TypeError):
|
||||
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
|
||||
_, mime, data = parts['file']
|
||||
mime = (mime or 'application/octet-stream').lower()
|
||||
if len(data) > CAPABILITY['maxFileBytes']:
|
||||
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
identity = (filename, mime, digest)
|
||||
if upload_id in uploads:
|
||||
old_identity, attachment_id = uploads[upload_id]
|
||||
if old_identity != identity:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
|
||||
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
|
||||
kind = 'file'
|
||||
if mime in CAPABILITY['imageMimeTypes']:
|
||||
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
|
||||
elif mime.startswith('text/'):
|
||||
try:
|
||||
if '\0' not in data.decode('utf-8-sig'):
|
||||
kind = 'text'
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
attachment_id = 'fixture-' + str(uuid.uuid4())
|
||||
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
|
||||
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
|
||||
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
|
||||
uploads[upload_id] = (identity, attachment_id)
|
||||
return web.json_response({'attachment': descriptor}, status=201)
|
||||
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
|
||||
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
|
||||
stored = attachments.get(attachment_id)
|
||||
if stored is None:
|
||||
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
|
||||
descriptor = stored['descriptor']
|
||||
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
|
||||
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
|
||||
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
|
||||
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']
|
||||
refs = body.get('attachments', [])
|
||||
ids = [ref.get('id') for ref in refs]
|
||||
if refs and not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
|
||||
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
|
||||
if any(key not in attachments for key in ids):
|
||||
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
|
||||
if not content.strip() and not ids:
|
||||
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
|
||||
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
|
||||
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 ids:
|
||||
receipt['attachmentIds'] = ids
|
||||
if key not in requests:
|
||||
requests[key] = dict(receipt, _payload=[content, ids])
|
||||
descriptors = [attachments[key]['descriptor'] for key in ids]
|
||||
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
|
||||
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
|
||||
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({k: v for k, v in requests[key].items() if k != '_payload'}, 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)
|
||||
if blocked_proxy:
|
||||
await socket.close(code=4503, message=b'attachments_unavailable')
|
||||
return socket
|
||||
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(client_max_size=8 * 1024 * 1024 + 64 * 1024)
|
||||
app.router.add_route('*', '/{path:.*}', route)
|
||||
web.run_app(app, host='127.0.0.1', port=8799)
|
||||
@@ -1,350 +0,0 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@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()
|
||||
try clearComposer()
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
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 clearComposer() throws {
|
||||
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
|
||||
composer.tap(); composer.press(forDuration: 1)
|
||||
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
|
||||
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
|
||||
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
|
||||
let value = composer.value as? String ?? ""
|
||||
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
|
||||
}
|
||||
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])
|
||||
}
|
||||
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
|
||||
let boundary = "Fixture-" + UUID().uuidString
|
||||
let uploadID = UUID().uuidString
|
||||
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
|
||||
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
|
||||
request.httpMethod = "POST"; request.httpBody = body
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
return try XCTUnwrap(json["attachment"] as? [String: Any])
|
||||
}
|
||||
|
||||
func testAttachmentHistoryDownloadAndReload() async throws {
|
||||
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
||||
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
|
||||
}
|
||||
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
|
||||
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
|
||||
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
|
||||
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
|
||||
let path = try XCTUnwrap(file["workspacePath"] as? String)
|
||||
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
|
||||
let download = app.buttons["agent-download"].firstMatch
|
||||
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
|
||||
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
|
||||
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
|
||||
capture("agent-attachment-original-and-preview")
|
||||
try await control(["action": "long-tool"])
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
capture("agent-attachment-reloaded")
|
||||
// The real app's authenticated content request is recorded by the loopback fixture.
|
||||
let state = try await control(["action": "state"])
|
||||
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
||||
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
|
||||
}
|
||||
|
||||
func testAttachmentCapabilityAndPickerCancellation() async throws {
|
||||
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
|
||||
composer.tap(); composer.typeText("Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap()
|
||||
app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
capture("agent-attachment-pickers-cancelled")
|
||||
try await control(["action": "capability", "enabled": false])
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
try clearComposer()
|
||||
capture("agent-text-only-capability")
|
||||
}
|
||||
|
||||
func testPhotoSelectionToAttachmentOnlySend() async throws {
|
||||
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
|
||||
// Only synthetic assets are touched; no unrelated photo bytes are read.
|
||||
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
|
||||
// XCTest's synchronous UI waits must not block the authorization request.
|
||||
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
|
||||
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
|
||||
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
|
||||
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
|
||||
}
|
||||
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
|
||||
// Recover only fixtures from an interrupted test, identified by BOTH our unique
|
||||
// date and original-filename prefix, before creating this run's asset.
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
|
||||
let previous = PHAsset.fetchAssets(with: .image, options: options)
|
||||
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
|
||||
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
|
||||
}.map(\.localIdentifier)
|
||||
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
|
||||
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
|
||||
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
|
||||
}
|
||||
try XCTUnwrap(photo.pngData()).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
nonisolated(unsafe) var assetID: String?
|
||||
try await PHPhotoLibrary.shared().performChanges { @Sendable in
|
||||
let creation = PHAssetCreationRequest.forAsset()
|
||||
// A unique synthetic date lets us find this asset without enumerating other photos.
|
||||
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
|
||||
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
|
||||
creation.addResource(with: .photo, fileURL: url, options: options)
|
||||
assetID = creation.placeholderForCreatedAsset?.localIdentifier
|
||||
}
|
||||
let ownedID = try XCTUnwrap(assetID)
|
||||
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
|
||||
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
|
||||
do { try await exercisePhotoSelection() }
|
||||
catch {
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
throw error
|
||||
}
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
}
|
||||
|
||||
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Self.eraseSyntheticPhotos(identifiers)
|
||||
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
|
||||
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
|
||||
}
|
||||
|
||||
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Task.detached {
|
||||
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
|
||||
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
|
||||
XCTAssertEqual(remaining, 0)
|
||||
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
|
||||
}.value
|
||||
}
|
||||
|
||||
private func exercisePhotoSelection() async throws {
|
||||
app.activate()
|
||||
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
|
||||
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 8))
|
||||
cell.tap()
|
||||
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
|
||||
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
|
||||
await fulfillment(of: [ready], timeout: 15)
|
||||
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
|
||||
capture("agent-photo-upload-ready")
|
||||
app.buttons["agent-send"].tap()
|
||||
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
|
||||
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
let submission = try XCTUnwrap(requests.values.first)
|
||||
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
|
||||
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
|
||||
capture("agent-photo-attachment-only-canonical")
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
capture("agent-photo-attachment-only-reloaded")
|
||||
}
|
||||
|
||||
func testSendQueueToolsAndRetry() async throws {
|
||||
capture("agent-history")
|
||||
send("first fixture")
|
||||
send("second fixture")
|
||||
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-replies-keyboard")
|
||||
app.swipeDown()
|
||||
send("retry fixture")
|
||||
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
|
||||
capture("agent-retry")
|
||||
app.buttons["agent-retry"].tap()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
send("echo wins fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
composer.tap(); app.swipeDown()
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
|
||||
capture("agent-hidden-tools")
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
||||
}
|
||||
func testReduceMotionSend() async throws {
|
||||
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
||||
send("reduce motion fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.otherElements["agent-typing"].exists)
|
||||
capture("agent-reduce-motion")
|
||||
}
|
||||
|
||||
func testToolResultsStayHiddenLiveAndAfterReload() async throws {
|
||||
try await control(["action": "long-tool"])
|
||||
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
capture("agent-hidden-tool-live")
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
|
||||
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
|
||||
capture("agent-hidden-tool-history")
|
||||
}
|
||||
|
||||
func testTabletSidebarAndInactiveChat() async throws {
|
||||
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
|
||||
app.buttons["New chat"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "iPad inactive chat reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-ipad-sidebar")
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
|
||||
capture("agent-ipad-room-before-send")
|
||||
send("iPad fixture")
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
|
||||
capture("agent-ipad-room")
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
func testScrollPreservationAndKeyboard() async throws {
|
||||
app.terminate()
|
||||
try await control(["action": "reset", "count": 160])
|
||||
app.launch(); openAgent()
|
||||
let scroll = app.scrollViews.firstMatch
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown()
|
||||
scroll.swipeDown()
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||
let before = anchor.frame.minY
|
||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
capture("agent-reading-anchor")
|
||||
for _ in 0..<25 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
app.buttons["Load earlier messages"].tap()
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
||||
capture("agent-older-history-anchor")
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
capture("agent-multiline-keyboard")
|
||||
}
|
||||
|
||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||
app.buttons["Show sidebar"].tap()
|
||||
try await control(["action": "busy", "value": true])
|
||||
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [unread], timeout: 5)
|
||||
capture("agent-unread-sidebar")
|
||||
let covered = try await control(["action": "state"])
|
||||
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
|
||||
try await control(["action": "read"])
|
||||
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
|
||||
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
|
||||
await fulfillment(of: [cleared], timeout: 20)
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
|
||||
try await control(["action": "disconnect"])
|
||||
try await control(["action": "reply", "content": "Reconnect fixture reply"])
|
||||
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
|
||||
try await control(["action": "malformed"])
|
||||
try await control(["action": "reply", "content": "Recovered malformed frame"])
|
||||
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
|
||||
XCUIDevice.shared.press(.home)
|
||||
try await control(["action": "reply", "content": "Foreground fixture reply"])
|
||||
app.activate()
|
||||
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
|
||||
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
|
||||
capture("agent-foreground-replay")
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
/// Loopback-only regressions. No picker selection or external backend writes.
|
||||
@MainActor
|
||||
final class TranscriptFadeUITests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
try await control(["action": "reset", "count": 160])
|
||||
try await control(["action": "transcript-comparison"])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
|
||||
private func showSidebar() {
|
||||
if !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))
|
||||
}
|
||||
|
||||
private func openAgent() {
|
||||
showSidebar()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
|
||||
}
|
||||
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
|
||||
private var transcript: XCUIElement {
|
||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
||||
}
|
||||
|
||||
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, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
func testSharedHeaderFadeGeometry() async throws {
|
||||
showSidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
|
||||
// Normal chat is lazy and can open at either end on phone/tablet.
|
||||
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
|
||||
XCTAssertTrue(normal.waitForExistence(timeout: 8))
|
||||
let normalTop = normal.frame.minY
|
||||
let first = app.staticTexts["History message 1: local simulator fixture."]
|
||||
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
|
||||
else { normal.swipeDown(velocity: .slow) }
|
||||
capture("normal-chat-content-crossing-shared-fade")
|
||||
XCTAssertTrue(app.buttons["Attach file"].isHittable)
|
||||
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(scroll.exists)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
capture("agent-content-crossing-shared-fade")
|
||||
XCTAssertTrue(composer.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-send"].exists)
|
||||
// A viewport padded below the header clips bubbles before they reach its fade.
|
||||
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
|
||||
"Agent must scroll beneath the same workspace header as normal chat")
|
||||
}
|
||||
|
||||
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
// Stay below the header's content clearance, where text is readable.
|
||||
let visibleTop = scroll.frame.minY + 100
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
||||
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
|
||||
})
|
||||
let before = anchor.frame.minY
|
||||
let stateBefore = try await control(["action": "state"])
|
||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
|
||||
await fulfillment(of: [unread], timeout: 8)
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
let stateAfter = try await control(["action": "state"])
|
||||
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
||||
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
|
||||
capture("agent-fade-reading-position-and-unread")
|
||||
|
||||
for _ in 0..<30 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
let load = app.buttons["Load earlier messages"]
|
||||
XCTAssertTrue(load.isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
load.tap()
|
||||
capture("agent-fade-prepend-reading-position")
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
||||
"Prepending must preserve the readable row's position beneath the header")
|
||||
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
composer.tap()
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
capture("agent-fade-latest-with-keyboard")
|
||||
// No send: this only changes a local draft, then clears it.
|
||||
composer.typeText("Fade draft\nsecond line\nthird line")
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
app_identifier("net.buzzert.sybil2")
|
||||
team_id("DQQH5H6GBD")
|
||||
require "dotenv"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||
|
||||
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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, creates the runner user's
|
||||
login keychain from Gitea secrets, makes that keychain the user default for the
|
||||
duration of the job, installs the App Store provisioning profile in both the
|
||||
legacy MobileDevice directory and the Xcode UserData directory used by newer
|
||||
Xcode releases, builds and uploads the app with fastlane, then creates or
|
||||
updates the matching Gitea release with the generated IPA as an asset. The job
|
||||
restores the previous user default keychain and deletes the user login keychain
|
||||
and installed profiles in an `always()` cleanup step. No signing material is
|
||||
installed into the system keychain.
|
||||
|
||||
Required repository secrets:
|
||||
|
||||
```text
|
||||
APP_STORE_CONNECT_API_KEY_ID
|
||||
APP_STORE_CONNECT_API_ISSUER_ID
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT
|
||||
APPSTORE_CERTIFICATES_FILE_BASE64
|
||||
APPSTORE_CERTIFICATES_PASSWORD
|
||||
APPSTORE_PROVISIONING_PROFILE_BASE64
|
||||
```
|
||||
|
||||
Generate or refresh the signing assets locally with:
|
||||
|
||||
```sh
|
||||
cd ios
|
||||
fastlane ios create_ci_signing
|
||||
```
|
||||
|
||||
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
|
||||
certificate and provisioning profile values into the repository secrets listed
|
||||
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
|
||||
default.
|
||||
|
||||
Fastlane keeps two signing names separate. `SYBIL_CODE_SIGN_IDENTITY` is the
|
||||
exact certificate common name used when exporting a local p12 for secrets, while
|
||||
`SYBIL_XCODE_CODE_SIGN_IDENTITY` defaults to the certificate SHA-1 fingerprint
|
||||
that Xcode uses during archive. `SYBIL_EXPORT_SIGNING_CERTIFICATE` defaults to
|
||||
the generic `Apple Distribution` selector used in the export options.
|
||||
|
||||
The Release signing settings are also present in `Apps/Sybil/project.yml` so
|
||||
XcodeGen emits a manually signed App Store archive configuration. CI passes the
|
||||
installed provisioning profile UUID to Fastlane as
|
||||
`SYBIL_PROVISIONING_PROFILE_UUID`; Fastlane writes that UUID into the generated
|
||||
project before archiving. CI also passes the temporary keychain path as
|
||||
`CODE_SIGN_KEYCHAIN` so Xcode searches the disposable keychain for the imported
|
||||
Distribution identity.
|
||||
|
||||
If the Apple team has reached the Distribution certificate limit, set
|
||||
`SYBIL_SIGNING_CERTIFICATE_ID` to the portal id for a certificate whose private
|
||||
key exists in the local login keychain before running `create_ci_signing`. The
|
||||
lane will export the local identity and create the provisioning profile against
|
||||
that existing certificate instead of creating another Distribution certificate.
|
||||
|
||||
If `create_ci_signing` fails with an expired or missing agreement error, the
|
||||
Apple Developer Program account holder must accept the current agreements in
|
||||
App Store Connect before new certificates or provisioning profiles can be
|
||||
created through the API.
|
||||
|
||||
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
|
||||
upload, with `contents: write` permissions. In Gitea this covers release asset
|
||||
publication.
|
||||
+469
-30
@@ -1,47 +1,486 @@
|
||||
require "dotenv"
|
||||
require "base64"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "net/http"
|
||||
require "open3"
|
||||
require "openssl"
|
||||
require "securerandom"
|
||||
require "shellwords"
|
||||
require "uri"
|
||||
require "yaml"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build a release tag and upload it to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci
|
||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
|
||||
SIGNING_CERTIFICATE_NAME = ENV["SYBIL_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution: James Magahern (DQQH5H6GBD)" : ENV["SYBIL_CODE_SIGN_IDENTITY"]
|
||||
XCODE_CODE_SIGN_IDENTITY = ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "6B74B268C4761720FB2051D01D8BB3E47B55D9F5" : ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"]
|
||||
EXPORT_SIGNING_CERTIFICATE = ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"].to_s.strip.empty? ? "Apple Distribution" : ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"]
|
||||
IOS_ROOT = File.expand_path("..", __dir__)
|
||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
|
||||
SCHEME = "Sybil"
|
||||
TARGET = "SybilApp"
|
||||
|
||||
match(type: "appstore")
|
||||
def present?(value)
|
||||
!value.to_s.strip.empty?
|
||||
end
|
||||
|
||||
tag = ENV.fetch("GITHUB_REF_NAME")
|
||||
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
||||
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
|
||||
def capture(command)
|
||||
stdout, stderr, status = Open3.capture3(command)
|
||||
return stdout.strip if status.success?
|
||||
|
||||
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/)
|
||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
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}")
|
||||
def run_silent(*command, error_message:)
|
||||
_stdout, stderr, status = Open3.capture3(*command)
|
||||
return if status.success?
|
||||
|
||||
UI.user_error!("#{error_message}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
def user_keychains
|
||||
capture("security list-keychains -d user").lines.map { |line| line.strip.delete('"') }.reject(&:empty?)
|
||||
end
|
||||
|
||||
def app_project_settings
|
||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||
end
|
||||
|
||||
def apply_release_signing_settings
|
||||
require "xcodeproj"
|
||||
|
||||
project = Xcodeproj::Project.open(PROJECT_FILE)
|
||||
target = project.targets.find { |candidate| candidate.name == TARGET }
|
||||
UI.user_error!("Could not find target #{TARGET} in #{PROJECT_FILE}") unless target
|
||||
|
||||
target.build_configurations.each do |configuration|
|
||||
next unless configuration.name == "Release"
|
||||
|
||||
settings = configuration.build_settings
|
||||
settings["CODE_SIGN_STYLE"] = "Manual"
|
||||
settings["DEVELOPMENT_TEAM"] = TEAM_ID
|
||||
settings["PROVISIONING_PROFILE_SPECIFIER"] = PROFILE_SPECIFIER
|
||||
settings["CODE_SIGN_IDENTITY"] = XCODE_CODE_SIGN_IDENTITY
|
||||
settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = XCODE_CODE_SIGN_IDENTITY
|
||||
settings["CODE_SIGN_KEYCHAIN"] = ENV["SYBIL_SIGNING_KEYCHAIN_PATH"] if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
|
||||
if present?(ENV["SYBIL_PROVISIONING_PROFILE_UUID"])
|
||||
settings["PROVISIONING_PROFILE"] = ENV["SYBIL_PROVISIONING_PROFILE_UUID"]
|
||||
settings["PROVISIONING_PROFILE[sdk=iphoneos*]"] = ENV["SYBIL_PROVISIONING_PROFILE_UUID"]
|
||||
end
|
||||
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
|
||||
File.write(project_spec, contents)
|
||||
end
|
||||
project.save
|
||||
end
|
||||
|
||||
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
|
||||
def local_marketing_version
|
||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||
end
|
||||
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_content: ENV.fetch("ASC_KEY"),
|
||||
is_key_content_base64: true
|
||||
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.to_s.shellescape}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def env_line(key, value)
|
||||
"#{key}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def base64url(value)
|
||||
Base64.urlsafe_encode64(value).delete("=")
|
||||
end
|
||||
|
||||
def integer_to_fixed_bytes(integer, length)
|
||||
hex = integer.to_s(16)
|
||||
hex = "0#{hex}" if hex.length.odd?
|
||||
[hex].pack("H*").rjust(length, "\0")[-length, length]
|
||||
end
|
||||
|
||||
def app_store_connect_private_key
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
|
||||
pem = if present?(key_path)
|
||||
File.read(key_path)
|
||||
elsif present?(key_content)
|
||||
ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true" ? Base64.decode64(key_content) : key_content
|
||||
end
|
||||
UI.user_error!("App Store Connect API key content is required") unless present?(pem)
|
||||
|
||||
OpenSSL::PKey::EC.new(pem)
|
||||
end
|
||||
|
||||
def app_store_connect_jwt
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||
UI.user_error!("App Store Connect API key id and issuer id are required") unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
header = { alg: "ES256", kid: key_id, typ: "JWT" }
|
||||
payload = { iss: issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + 600, aud: "appstoreconnect-v1" }
|
||||
unsigned = [base64url(header.to_json), base64url(payload.to_json)].join(".")
|
||||
asn1_signature = app_store_connect_private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(unsigned))
|
||||
signature_sequence = OpenSSL::ASN1.decode(asn1_signature)
|
||||
raw_signature = signature_sequence.value.map { |part| integer_to_fixed_bytes(part.value, 32) }.join
|
||||
[unsigned, base64url(raw_signature)].join(".")
|
||||
end
|
||||
|
||||
def app_store_connect_request(method, path, payload = nil)
|
||||
uri = URI("https://api.appstoreconnect.apple.com#{path}")
|
||||
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
||||
request = request_class.new(uri)
|
||||
request["Authorization"] = "Bearer #{app_store_connect_jwt}"
|
||||
if payload
|
||||
request["Content-Type"] = "application/json"
|
||||
request.body = payload.to_json
|
||||
end
|
||||
|
||||
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
||||
return {} if response.is_a?(Net::HTTPSuccess) && response.body.to_s.empty?
|
||||
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
UI.user_error!("App Store Connect API request failed: #{method.to_s.upcase} #{path}\n#{response.body}")
|
||||
end
|
||||
|
||||
def bundle_id_resource_id
|
||||
response = app_store_connect_request(
|
||||
:get,
|
||||
"/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1"
|
||||
)
|
||||
id = response.fetch("data", []).first&.fetch("id", nil)
|
||||
UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id)
|
||||
id
|
||||
end
|
||||
|
||||
def recreate_app_store_profile(certificate_id)
|
||||
existing = app_store_connect_request(
|
||||
:get,
|
||||
"/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200"
|
||||
)
|
||||
existing.fetch("data", []).each do |profile|
|
||||
app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}")
|
||||
end
|
||||
|
||||
payload = {
|
||||
data: {
|
||||
type: "profiles",
|
||||
attributes: {
|
||||
name: PROFILE_SPECIFIER,
|
||||
profileType: "IOS_APP_STORE"
|
||||
},
|
||||
relationships: {
|
||||
bundleId: {
|
||||
data: { type: "bundleIds", id: bundle_id_resource_id }
|
||||
},
|
||||
certificates: {
|
||||
data: [{ type: "certificates", id: certificate_id }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response = app_store_connect_request(:post, "/v1/profiles", payload)
|
||||
profile_content = response.dig("data", "attributes", "profileContent")
|
||||
UI.user_error!("App Store Connect profile response did not include profileContent") unless present?(profile_content)
|
||||
|
||||
profile_path = File.join(SIGNING_OUTPUT_DIR, "Sybil_AppStore_CI.mobileprovision")
|
||||
File.binwrite(profile_path, Base64.decode64(profile_content))
|
||||
install_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
|
||||
FileUtils.mkdir_p(install_dir)
|
||||
FileUtils.cp(profile_path, File.join(install_dir, "Sybil_AppStore_CI.mobileprovision"))
|
||||
profile_path
|
||||
end
|
||||
|
||||
def signing_identity_p12(p12_path, p12_password)
|
||||
work_dir = File.join(SIGNING_OUTPUT_DIR, "single-identity")
|
||||
FileUtils.rm_rf(work_dir)
|
||||
FileUtils.mkdir_p(work_dir)
|
||||
|
||||
all_pem = File.join(work_dir, "all.pem")
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"openssl", "pkcs12",
|
||||
"-in", p12_path,
|
||||
"-nodes",
|
||||
"-passin", "pass:#{p12_password}",
|
||||
"-out", all_pem
|
||||
)
|
||||
UI.user_error!("Could not inspect exported p12\n#{stderr}\n#{stdout}") unless status.success?
|
||||
|
||||
records = File.read(all_pem).split(/(?=Bag Attributes\n)/)
|
||||
cert_record = records.find do |record|
|
||||
record.include?("friendlyName: #{SIGNING_CERTIFICATE_NAME}") && record.include?("-----BEGIN CERTIFICATE-----")
|
||||
end
|
||||
UI.user_error!("Could not find #{SIGNING_CERTIFICATE_NAME} certificate in exported p12") unless cert_record
|
||||
|
||||
local_key_id = cert_record[/localKeyID: (.+)/, 1].to_s.strip
|
||||
UI.user_error!("Could not resolve localKeyID for #{SIGNING_CERTIFICATE_NAME}") unless present?(local_key_id)
|
||||
|
||||
key_record = records.find do |record|
|
||||
record.include?("localKeyID: #{local_key_id}") && record.include?("-----BEGIN PRIVATE KEY-----")
|
||||
end
|
||||
UI.user_error!("Could not find private key for #{SIGNING_CERTIFICATE_NAME}") unless key_record
|
||||
|
||||
cert_path = File.join(work_dir, "identity.cer.pem")
|
||||
key_path = File.join(work_dir, "identity.key.pem")
|
||||
File.write(cert_path, cert_record[/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m])
|
||||
File.write(key_path, key_record[/-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----/m])
|
||||
|
||||
root_cer = File.join(work_dir, "AppleIncRootCertificate.cer")
|
||||
wwdr_cer = File.join(work_dir, "AppleWWDRCAG3.cer")
|
||||
root_pem = File.join(work_dir, "AppleIncRootCertificate.pem")
|
||||
wwdr_pem = File.join(work_dir, "AppleWWDRCAG3.pem")
|
||||
chain_pem = File.join(work_dir, "chain.pem")
|
||||
run_silent("curl", "-fsSL", "https://www.apple.com/appleca/AppleIncRootCertificate.cer", "-o", root_cer, error_message: "Could not download Apple root certificate")
|
||||
run_silent("curl", "-fsSL", "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer", "-o", wwdr_cer, error_message: "Could not download Apple WWDR G3 certificate")
|
||||
run_silent("openssl", "x509", "-inform", "DER", "-in", root_cer, "-out", root_pem, error_message: "Could not convert Apple root certificate")
|
||||
run_silent("openssl", "x509", "-inform", "DER", "-in", wwdr_cer, "-out", wwdr_pem, error_message: "Could not convert Apple WWDR G3 certificate")
|
||||
File.write(chain_pem, File.read(wwdr_pem) + File.read(root_pem))
|
||||
|
||||
single_p12_path = File.join(work_dir, "appstore-signing.p12")
|
||||
run_silent(
|
||||
"openssl", "pkcs12", "-export",
|
||||
"-inkey", key_path,
|
||||
"-in", cert_path,
|
||||
"-certfile", chain_pem,
|
||||
"-name", SIGNING_CERTIFICATE_NAME,
|
||||
"-out", single_p12_path,
|
||||
"-passout", "pass:#{p12_password}",
|
||||
error_message: "Could not create single-identity p12"
|
||||
)
|
||||
FileUtils.cp(single_p12_path, p12_path)
|
||||
ensure
|
||||
FileUtils.rm_rf(work_dir) if present?(work_dir)
|
||||
end
|
||||
|
||||
def app_store_connect_key_options
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||
return nil unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
if present?(key_path)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_filepath: key_path
|
||||
}
|
||||
elsif present?(key_content)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_content: key_content,
|
||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
private_lane :load_app_store_connect_api_key do
|
||||
options = app_store_connect_key_options
|
||||
UI.user_error!("App Store Connect API key is required") unless options
|
||||
|
||||
app_store_connect_api_key(options)
|
||||
end
|
||||
|
||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||
lane :version do
|
||||
UI.message("Git tag version: #{release_version}")
|
||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||
UI.message("Checked-in build number: #{local_build_number}")
|
||||
end
|
||||
|
||||
desc "Create CI signing certificate/profile and write ignored secret material under build/signing"
|
||||
lane :create_ci_signing do
|
||||
api_key = load_app_store_connect_api_key
|
||||
|
||||
FileUtils.rm_rf(SIGNING_OUTPUT_DIR)
|
||||
FileUtils.mkdir_p(SIGNING_OUTPUT_DIR)
|
||||
|
||||
cert_id = ENV["SYBIL_SIGNING_CERTIFICATE_ID"].to_s
|
||||
keychain_path = nil
|
||||
keychain_password = nil
|
||||
p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12")
|
||||
p12_password = ENV["SYBIL_CI_P12_PASSWORD"].to_s
|
||||
if p12_password.empty?
|
||||
p12_password = SecureRandom.base64(24)
|
||||
UI.important("Generated a p12 password for CI secrets.")
|
||||
end
|
||||
|
||||
begin
|
||||
if present?(cert_id)
|
||||
UI.message("Using existing signing certificate id #{cert_id}")
|
||||
export_keychain = ENV["SYBIL_SIGNING_KEYCHAIN"].to_s
|
||||
export_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") unless present?(export_keychain)
|
||||
run_silent(
|
||||
"security", "export", "-k", export_keychain, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||
error_message: "Could not export the local CI signing identity"
|
||||
)
|
||||
else
|
||||
keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db")
|
||||
keychain_password = SecureRandom.base64(24)
|
||||
run_silent(
|
||||
"security", "create-keychain", "-p", keychain_password, keychain_path,
|
||||
error_message: "Could not create temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "set-keychain-settings", "-lut", "21600", keychain_path,
|
||||
error_message: "Could not configure temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "unlock-keychain", "-p", keychain_password, keychain_path,
|
||||
error_message: "Could not unlock temporary signing keychain"
|
||||
)
|
||||
run_silent(
|
||||
"security", "list-keychains", "-d", "user", "-s", keychain_path, *user_keychains,
|
||||
error_message: "Could not add temporary signing keychain to the user search list"
|
||||
)
|
||||
|
||||
cert(
|
||||
api_key: api_key,
|
||||
development: false,
|
||||
force: true,
|
||||
generate_apple_certs: true,
|
||||
keychain_password: keychain_password,
|
||||
keychain_path: keychain_path,
|
||||
output_path: SIGNING_OUTPUT_DIR,
|
||||
platform: "ios"
|
||||
)
|
||||
|
||||
cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID]
|
||||
UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id)
|
||||
run_silent(
|
||||
"security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||
error_message: "Could not export the generated CI signing identity"
|
||||
)
|
||||
end
|
||||
|
||||
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
|
||||
signing_identity_p12(p12_path, p12_password)
|
||||
|
||||
profile_path = recreate_app_store_profile(cert_id)
|
||||
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
|
||||
|
||||
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
|
||||
File.write(
|
||||
secrets_path,
|
||||
[
|
||||
env_line("APPSTORE_CERTIFICATES_FILE_BASE64", Base64.strict_encode64(File.binread(p12_path))),
|
||||
env_line("APPSTORE_CERTIFICATES_PASSWORD", p12_password),
|
||||
env_line("APPSTORE_PROVISIONING_PROFILE_BASE64", Base64.strict_encode64(File.binread(profile_path))),
|
||||
env_line("SYBIL_PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER)
|
||||
].join("\n") + "\n"
|
||||
)
|
||||
ensure
|
||||
system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if present?(keychain_path) && File.exist?(keychain_path)
|
||||
end
|
||||
|
||||
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
|
||||
UI.important("Add the values from #{secrets_path} as repository secrets.")
|
||||
end
|
||||
|
||||
desc "Build Sybil and upload it to TestFlight"
|
||||
lane :beta do
|
||||
version = release_version
|
||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||
api_key = load_app_store_connect_api_key
|
||||
|
||||
unless present?(build_number)
|
||||
build_number = (local_build_number + 1).to_s
|
||||
|
||||
begin
|
||||
latest = latest_testflight_build_number(
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
version: version,
|
||||
api_key: api_key,
|
||||
initial_build_number: local_build_number
|
||||
).to_i
|
||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||
rescue StandardError => e
|
||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||
end
|
||||
end
|
||||
|
||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||
apply_release_signing_settings
|
||||
|
||||
xcode_args = [
|
||||
xcode_build_setting("MARKETING_VERSION", version),
|
||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number),
|
||||
xcode_build_setting("CODE_SIGN_STYLE", "Manual"),
|
||||
xcode_build_setting("DEVELOPMENT_TEAM", TEAM_ID),
|
||||
xcode_build_setting("PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER),
|
||||
xcode_build_setting("CODE_SIGN_IDENTITY", XCODE_CODE_SIGN_IDENTITY)
|
||||
]
|
||||
if present?(ENV["SYBIL_PROVISIONING_PROFILE_UUID"])
|
||||
xcode_args << xcode_build_setting("PROVISIONING_PROFILE", ENV.fetch("SYBIL_PROVISIONING_PROFILE_UUID"))
|
||||
end
|
||||
if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
|
||||
xcode_args << xcode_build_setting("CODE_SIGN_KEYCHAIN", ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH"))
|
||||
xcode_args << xcode_build_setting("OTHER_CODE_SIGN_FLAGS", "--keychain #{ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH")}")
|
||||
end
|
||||
xcode_args = xcode_args.join(" ")
|
||||
|
||||
ipa_path = build_app(
|
||||
project: PROJECT_FILE,
|
||||
scheme: SCHEME,
|
||||
clean: true,
|
||||
sdk: "iphoneos",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||
xcargs: xcode_args,
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
destination: "export",
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: {
|
||||
APP_IDENTIFIER => PROFILE_SPECIFIER
|
||||
},
|
||||
signingCertificate: EXPORT_SIGNING_CERTIFICATE,
|
||||
teamID: TEAM_ID,
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
uploadSymbols: true,
|
||||
stripSwiftSymbols: true
|
||||
}
|
||||
)
|
||||
|
||||
build_app(
|
||||
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
||||
scheme: "Sybil"
|
||||
)
|
||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
ipa: ipa_path,
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
|
||||
app_identifier(["net.buzzert.sybil2"])
|
||||
team_id("DQQH5H6GBD")
|
||||
profile_name("Sybil AppStore CI")
|
||||
@@ -0,0 +1,48 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios version
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios version
|
||||
```
|
||||
|
||||
Show the version Fastlane will stamp into the next TestFlight archive
|
||||
|
||||
### ios create_ci_signing
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios create_ci_signing
|
||||
```
|
||||
|
||||
Create CI signing certificate/profile and write ignored secret material under build/signing
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Build Sybil and upload it to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
+1
-7
@@ -9,7 +9,7 @@ generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
@@ -24,12 +24,6 @@ run: generate
|
||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||
xcrun simctl launch booted net.buzzert.sybil2
|
||||
|
||||
build-mac: generate
|
||||
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
run-mac: build-mac
|
||||
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
|
||||
@@ -7,32 +7,3 @@ packages:
|
||||
path: Packages/Sybil
|
||||
include:
|
||||
- Apps/Sybil/project.yml
|
||||
- Apps/SybilMacQuickQuestion/project.yml
|
||||
|
||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||
targets:
|
||||
SybilAgentUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- Tests/SybilUITests
|
||||
dependencies:
|
||||
- target: SybilApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: Synthetic attachment UI tests create and remove their own photo.
|
||||
TEST_TARGET_NAME: SybilApp
|
||||
SWIFT_VERSION: 6.0
|
||||
schemes:
|
||||
SybilAgentUI:
|
||||
build:
|
||||
targets:
|
||||
SybilApp: all
|
||||
SybilAgentUITests: [test]
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- SybilAgentUITests
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
|
||||
LOG_DIR="$ROOT/.devbox/logs"
|
||||
PID_DIR="$ROOT/.devbox/pids"
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env OPENAI_API_KEY
|
||||
require_env EXA_API_KEY
|
||||
|
||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||
|
||||
export HOST="${HOST:-0.0.0.0}"
|
||||
export PORT="${PORT:-8787}"
|
||||
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
|
||||
|
||||
echo "Installing server dependencies..."
|
||||
npm ci --prefix "$ROOT/server" --no-audit --no-fund
|
||||
|
||||
echo "Installing web dependencies..."
|
||||
npm ci --prefix "$ROOT/web" --no-audit --no-fund
|
||||
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
|
||||
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
|
||||
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
|
||||
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
|
||||
tmux set-environment -t "$SESSION" HOST "$HOST"
|
||||
tmux set-environment -t "$SESSION" PORT "$PORT"
|
||||
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
|
||||
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
|
||||
fi
|
||||
for name in \
|
||||
CHAT_CODEX_TOOL_ENABLED \
|
||||
CHAT_CODEX_REMOTE_HOST \
|
||||
CHAT_CODEX_REMOTE_USER \
|
||||
CHAT_CODEX_REMOTE_PORT \
|
||||
CHAT_CODEX_REMOTE_WORKDIR \
|
||||
CHAT_CODEX_SSH_KEY_PATH \
|
||||
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS \
|
||||
CHAT_SHELL_TOOL_ENABLED \
|
||||
CHAT_SHELL_EXEC_TIMEOUT_MS; do
|
||||
if [[ -n "${!name:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" "$name" "${!name}"
|
||||
fi
|
||||
done
|
||||
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
|
||||
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
|
||||
"npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
echo "Started Sybil in tmux session '$SESSION'."
|
||||
echo "Server: http://127.0.0.1:8787"
|
||||
echo "Web: http://127.0.0.1:5173"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "tmux not found; starting background processes with nohup."
|
||||
|
||||
if [[ -f "$PID_DIR/server.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$PID_DIR/web.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$ROOT/server"
|
||||
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
|
||||
) >"$LOG_DIR/server.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/server.pid"
|
||||
|
||||
(
|
||||
cd "$ROOT/web"
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||
) >"$LOG_DIR/web.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/web.pid"
|
||||
|
||||
echo "Started Sybil background processes."
|
||||
echo "Server log: $LOG_DIR/server.log"
|
||||
echo "Web log: $LOG_DIR/web.log"
|
||||
@@ -1,357 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO="${REPO:-Sybil-2}"
|
||||
WORKLOAD="${WORKLOAD:-sybil-dev}"
|
||||
CLUSTER="${CLUSTER:-viper}"
|
||||
NAMESPACE="${NAMESPACE:-buzzert}"
|
||||
QUOTA="${QUOTA:-research-api}"
|
||||
SECRET="${SECRET:-sybil-dev-env}"
|
||||
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
|
||||
INGRESS_PORT="${INGRESS_PORT:-5173}"
|
||||
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
|
||||
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
|
||||
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
|
||||
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
|
||||
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
|
||||
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
|
||||
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
|
||||
|
||||
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
|
||||
brix create \
|
||||
--project-root "$(dirname "$ROOT")" \
|
||||
--path "$REPO=$ROOT" \
|
||||
--repositories "$REPO" \
|
||||
--include-untracked \
|
||||
--reconcile-policy continuous \
|
||||
-- \
|
||||
"cluster=$CLUSTER" \
|
||||
"namespace=$NAMESPACE" \
|
||||
"name=$WORKLOAD" \
|
||||
"pool.spec.quota=$QUOTA" \
|
||||
"pool.spec.size.resource=cpu" \
|
||||
"pool.spec.size.quantity=1" \
|
||||
"pool.spec.priority=low"
|
||||
|
||||
if [[ "$REPO" != "openai" ]]; then
|
||||
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
|
||||
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
|
||||
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
|
||||
--from-file=id_ed25519="$tmpdir/id_ed25519" \
|
||||
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
fi
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
|
||||
--arg host "$CODEX_SERVICE" \
|
||||
--arg port "$CODEX_SSH_PORT" \
|
||||
--arg workdir "$CODEX_WORKDIR" \
|
||||
--arg keyPath "$CODEX_SSH_KEY_PATH" \
|
||||
'{
|
||||
stringData: {
|
||||
CHAT_CODEX_TOOL_ENABLED: "true",
|
||||
CHAT_CODEX_REMOTE_HOST: $host,
|
||||
CHAT_CODEX_REMOTE_USER: "root",
|
||||
CHAT_CODEX_REMOTE_PORT: $port,
|
||||
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
|
||||
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
|
||||
CHAT_SHELL_TOOL_ENABLED: "true"
|
||||
}
|
||||
}')"
|
||||
|
||||
ensure_codex_deployment() {
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
tolerations:
|
||||
- key: openai.com/team
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: $CODEX_CONTAINER
|
||||
image: $CODEX_IMAGE
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/bin/bash", "-lc"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if ! command -v sshd >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
npm install -g @openai/codex
|
||||
fi
|
||||
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
|
||||
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
|
||||
chmod 700 /root/.ssh
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
{
|
||||
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
|
||||
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
|
||||
fi
|
||||
if [[ -n "\${EXA_API_KEY:-}" ]]; then
|
||||
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
|
||||
fi
|
||||
} >/root/.sybil-codex-env
|
||||
chmod 600 /root/.sybil-codex-env
|
||||
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ -f /root/.sybil-codex-env ]]; then
|
||||
source /root/.sybil-codex-env
|
||||
fi
|
||||
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
|
||||
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
|
||||
fi
|
||||
exec /bin/bash -l
|
||||
SSH_COMMAND
|
||||
chmod 700 /usr/local/bin/sybil-codex-ssh-command
|
||||
ssh-keygen -A
|
||||
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
|
||||
Port $CODEX_SSH_PORT
|
||||
ListenAddress 0.0.0.0
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AcceptEnv OPENAI_API_KEY EXA_API_KEY
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
PermitUserEnvironment no
|
||||
ForceCommand /usr/local/bin/sybil-codex-ssh-command
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
Subsystem sftp internal-sftp
|
||||
PidFile /run/sshd/sybil-codex.pid
|
||||
SSHD_CONFIG
|
||||
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
|
||||
ports:
|
||||
- name: ssh
|
||||
containerPort: $CODEX_SSH_PORT
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: $SECRET
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
volumeMounts:
|
||||
- name: sybil-codex-ssh
|
||||
mountPath: /run/secrets/sybil-codex-ssh
|
||||
readOnly: true
|
||||
- name: sybil-codex-workspace
|
||||
mountPath: /workspace
|
||||
volumes:
|
||||
- name: sybil-codex-ssh
|
||||
secret:
|
||||
secretName: $CODEX_SSH_SECRET
|
||||
defaultMode: 384
|
||||
- name: sybil-codex-workspace
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
ports:
|
||||
- name: ssh
|
||||
port: $CODEX_SSH_PORT
|
||||
targetPort: ssh
|
||||
KUBE_MANIFEST
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
|
||||
}
|
||||
|
||||
ensure_codex_deployment
|
||||
|
||||
ensure_env_from() {
|
||||
local resource="$1"
|
||||
local name="$2"
|
||||
local jsonpath="$3"
|
||||
local patch_path="$4"
|
||||
local existing_env_from
|
||||
|
||||
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
|
||||
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
|
||||
|
||||
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local patch
|
||||
if [[ -z "$existing_env_from" ]]; then
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
|
||||
else
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
|
||||
fi
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
|
||||
}
|
||||
|
||||
ensure_env_from \
|
||||
workload \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
ensure_env_from \
|
||||
pool \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
|
||||
name: "sybil-codex-ssh",
|
||||
secret: {
|
||||
secretName: $secret,
|
||||
defaultMode: 384
|
||||
}
|
||||
}')"
|
||||
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
|
||||
|
||||
patch_pool_codex_mount() {
|
||||
local pool_json containers volumes patch
|
||||
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
|
||||
containers="$(jq \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
(.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName))
|
||||
' <<<"$pool_json")"
|
||||
volumes="$(jq \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
(.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume]
|
||||
' <<<"$pool_json")"
|
||||
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
|
||||
spec: {
|
||||
workers: {
|
||||
template: {
|
||||
spec: {
|
||||
containers: $containers,
|
||||
volumes: $volumes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount() {
|
||||
local workload_json pools patch
|
||||
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
|
||||
pools="$(jq \
|
||||
--arg groupName "$WORKLOAD" \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
.spec.pools
|
||||
| map(
|
||||
if .groupName == $groupName then
|
||||
.template.template.spec.workers.template.spec.containers =
|
||||
((.template.template.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName)))
|
||||
| .template.template.spec.workers.template.spec.volumes =
|
||||
((.template.template.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume])
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' <<<"$workload_json")"
|
||||
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount
|
||||
patch_pool_codex_mount
|
||||
|
||||
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
|
||||
deadline=$((SECONDS + 300))
|
||||
while true; do
|
||||
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
||||
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
|
||||
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
|
||||
|
||||
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
|
||||
|
||||
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
|
||||
|
||||
echo "Devbox update complete: $url"
|
||||
+4
-30
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
Backend API for:
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||
- Personal chat database (chats/messages + LLM call log)
|
||||
|
||||
## Stack
|
||||
@@ -43,16 +43,14 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `OPENAI_API_KEY`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `XAI_API_KEY`
|
||||
- `GEMINI_API_KEY`
|
||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||
- `EXA_API_KEY`
|
||||
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
|
||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)
|
||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
||||
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||
@@ -60,7 +58,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
|
||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||
|
||||
## API
|
||||
@@ -106,27 +104,3 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
||||
- User accounts + per-device API keys
|
||||
- Postgres support + migrations for prod
|
||||
- Attachments + embeddings + semantic search
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
```dotenv
|
||||
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
|
||||
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
|
||||
```
|
||||
|
||||
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
|
||||
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
|
||||
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
|
||||
`sybil-agent` resolves privately. Supply the key through a private server environment
|
||||
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
|
||||
`ADMIN_TOKEN` authentication for clients.
|
||||
|
||||
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
|
||||
same persistent root conversation across devices. Sybil Agent remains the source of
|
||||
truth for messages and background replies. Sybil stores only the shared,
|
||||
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
|
||||
its migration is applied automatically at startup. The bundled nginx configuration
|
||||
forwards WebSocket upgrades. See `docs/api/rest.md` and
|
||||
`docs/api/streaming-chat.md` at the repository root for the client contracts.
|
||||
|
||||
Generated
-33
@@ -22,12 +22,10 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -878,16 +876,6 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -2253,27 +2241,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -31,12 +31,10 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Add durable root grouping and one-time fork title generation state.
|
||||
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
||||
@@ -1,70 +0,0 @@
|
||||
-- External-content indexes avoid storing a second copy of conversation text.
|
||||
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
|
||||
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
|
||||
title, content='Chat', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
-- Hidden system prompts and tool payloads are not searchable conversation text.
|
||||
CREATE VIEW "MessageSearchContent" AS
|
||||
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
|
||||
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
|
||||
content, content='MessageSearchContent', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
|
||||
title, query, answerText, content='Search', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||
);
|
||||
|
||||
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
END;
|
||||
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
|
||||
WHEN old.title IS NOT new.title BEGIN
|
||||
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
|
||||
WHEN new.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
|
||||
WHEN old.role IN ('user', 'assistant') BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||
END;
|
||||
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
|
||||
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
|
||||
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
|
||||
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
|
||||
INSERT INTO "MessageSearch" (rowid, content)
|
||||
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
END;
|
||||
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
|
||||
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||
END;
|
||||
|
||||
-- Index existing history during migration, then maintain it transactionally.
|
||||
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
|
||||
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
|
||||
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
|
||||
|
||||
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE "AgentReadState" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"cursor" TEXT NOT NULL DEFAULT '0',
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
@@ -13,7 +13,6 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
gemini
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
@@ -51,8 +50,7 @@ model Chat {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
title String?
|
||||
titleGenerationPending Boolean @default(false)
|
||||
title String?
|
||||
|
||||
initiatedProvider Provider?
|
||||
initiatedModel String?
|
||||
@@ -65,18 +63,11 @@ model Chat {
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
// Forks always point directly to the single root chat, never to another fork.
|
||||
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
||||
parentChatId String?
|
||||
childChats Chat[] @relation("ChatForks")
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([parentChatId])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
model Message {
|
||||
@@ -215,11 +206,3 @@ model ProjectItem {
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
// Shared personal read position for the external persistent Sybil Agent room.
|
||||
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
|
||||
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
|
||||
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
|
||||
export const AgentAttachmentDescriptor = z.object({
|
||||
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
|
||||
});
|
||||
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
|
||||
|
||||
function apiError(statusCode: number, code: string, message: string) {
|
||||
return Object.assign(new Error(message), { statusCode, code });
|
||||
}
|
||||
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
|
||||
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
|
||||
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
|
||||
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
|
||||
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
|
||||
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
|
||||
}
|
||||
|
||||
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
|
||||
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
|
||||
await app.register(async routes => {
|
||||
let uploads = 0, downloads = 0;
|
||||
type Transfer = { controller: AbortController; release(): void };
|
||||
const transfers = new WeakMap<FastifyRequest, Transfer>();
|
||||
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
|
||||
routes.addHook('onRequest', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
|
||||
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
|
||||
const uploading = req.method === 'POST';
|
||||
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
|
||||
if (uploading) uploads++; else downloads++;
|
||||
const controller = new AbortController(); let released = false;
|
||||
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
|
||||
const release = () => {
|
||||
if (released) return; released = true;
|
||||
if (uploading) uploads--; else downloads--;
|
||||
clearTimeout(timeout); controller.abort();
|
||||
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
|
||||
};
|
||||
transfers.set(req, { controller, release });
|
||||
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
|
||||
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
|
||||
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
|
||||
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
await requireAgentAttachments(options.proxyJson, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
});
|
||||
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
|
||||
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
|
||||
if (reply.raw.destroyed) return;
|
||||
if (_req.method === 'POST') reply.header('Connection', 'close');
|
||||
const statusCode = error.statusCode || 502;
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
if (statusCode === 429) reply.header('Retry-After', '2');
|
||||
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
|
||||
statusCode, code: code || 'agent_unavailable' });
|
||||
});
|
||||
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
|
||||
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
|
||||
else done(null, payload);
|
||||
});
|
||||
const json = async (response: Response) => {
|
||||
let size = 0; const chunks: Uint8Array[] = [];
|
||||
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
};
|
||||
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
|
||||
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
|
||||
let oversized = false;
|
||||
const incoming = req.body;
|
||||
const { controller } = transfer(req);
|
||||
const body = Readable.from((async function* () {
|
||||
let count = 0;
|
||||
for await (const chunk of incoming) {
|
||||
count += chunk.length;
|
||||
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
|
||||
yield chunk;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
|
||||
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
|
||||
} as RequestInit & { duplex: string });
|
||||
const value = await json(response); controller.signal.throwIfAborted();
|
||||
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
|
||||
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
|
||||
if (response.status === 429) reply.header('Retry-After', '2');
|
||||
return reply.code(response.status).send(value);
|
||||
} catch (error) {
|
||||
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
|
||||
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
|
||||
} finally { body.destroy(); }
|
||||
});
|
||||
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
|
||||
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
|
||||
const { controller } = transfer(req);
|
||||
let response: Response;
|
||||
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
|
||||
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
|
||||
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
|
||||
controller.signal.throwIfAborted();
|
||||
if (!response.ok) return reply.code(response.status).send(await json(response));
|
||||
const length = Number(response.headers.get('content-length'));
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
const type = response.headers.get('content-type') || '';
|
||||
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|
||||
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
|
||||
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
|
||||
}
|
||||
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
|
||||
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
|
||||
if (size !== length) throw new Error('Download length mismatch');
|
||||
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
controller.signal.throwIfAborted();
|
||||
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(Buffer.concat(chunks, size));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.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().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
|
||||
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
|
||||
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(), attachmentIds: z.array(z.string()).optional() });
|
||||
|
||||
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;
|
||||
// An open development proxy must not expose a token-protected attachment runtime's
|
||||
// history/model context using its server-side credential.
|
||||
const guardOpenProxy = async (signal?: AbortSignal) => {
|
||||
if (env.ADMIN_TOKEN) return;
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||
});
|
||||
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
|
||||
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
|
||||
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
|
||||
};
|
||||
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
|
||||
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
|
||||
await guardOpenProxy(signal);
|
||||
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: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : 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;
|
||||
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
|
||||
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
|
||||
return descriptor;
|
||||
});
|
||||
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);
|
||||
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
|
||||
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}`
|
||||
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
|
||||
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();
|
||||
});
|
||||
|
||||
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>) {
|
||||
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, authenticating = 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', async (raw, isBinary) => {
|
||||
try {
|
||||
if (isBinary) throw new Error('JSON text frames are required');
|
||||
const frame = JSON.parse(raw.toString());
|
||||
if (!authenticated) {
|
||||
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
|
||||
authenticating = true;
|
||||
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; }
|
||||
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
|
||||
if (disposed || browser.readyState !== WebSocket.OPEN) return;
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
|
||||
send(data.toString());
|
||||
});
|
||||
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
|
||||
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
||||
const subscribe = parse(SubscribeFrame, frame);
|
||||
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
|
||||
upstream.send(JSON.stringify(subscribe));
|
||||
} catch (error) {
|
||||
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
|
||||
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
app.server.on('upgrade', upgrade);
|
||||
app.addHook('preClose', async () => {
|
||||
app.server.off('upgrade', upgrade);
|
||||
for (const stop of cleanup) stop();
|
||||
for (const client of sockets.clients) client.terminate();
|
||||
sockets.close();
|
||||
});
|
||||
}
|
||||
+1
-13
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
},
|
||||
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||
z.enum(["exa", "searxng"]).default("exa")
|
||||
);
|
||||
|
||||
const BooleanFlagSchema = z.preprocess((value) => {
|
||||
@@ -66,14 +66,10 @@ const EnvSchema = z.object({
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
XAI_API_KEY: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
|
||||
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||
@@ -103,14 +99,6 @@ const EnvSchema = z.object({
|
||||
});
|
||||
}
|
||||
|
||||
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["BRAVE_SEARCH_API_KEY"],
|
||||
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
|
||||
});
|
||||
}
|
||||
|
||||
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
|
||||
+1
-4
@@ -6,7 +6,6 @@ import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -27,7 +26,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
@@ -79,7 +78,6 @@ app.setErrorHandler((err, req, reply) => {
|
||||
error: true,
|
||||
message: e.message ?? String(e),
|
||||
statusCode,
|
||||
...(typeof e.code === 'string' ? { code: e.code } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +86,6 @@ app.addHook("onClose", async () => {
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
await registerAgentRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { convert as htmlToText } from "html-to-text";
|
||||
import { z } from "zod";
|
||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
import { searchBrave } from "../search/brave.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
@@ -508,33 +507,11 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
|
||||
};
|
||||
}
|
||||
|
||||
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
const response = await searchBrave(args.query, {
|
||||
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
||||
includeDomains: args.includeDomains,
|
||||
excludeDomains: args.excludeDomains,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
searchEngine: "brave",
|
||||
query: args.query,
|
||||
requestId: response.requestId,
|
||||
results: response.results.map((result, index) => ({
|
||||
rank: index + 1,
|
||||
...result,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
||||
const args = WebSearchArgsSchema.parse(input);
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
||||
return runSearxngWebSearchTool(args);
|
||||
}
|
||||
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
|
||||
return runBraveWebSearchTool(args);
|
||||
}
|
||||
return runExaWebSearchTool(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import {
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildTextAttachmentPrompt,
|
||||
buildTopLevelSystemPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
parseImageDataUrl,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
type GeminiClient = {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
function normalizeModelResourceName(model: string) {
|
||||
const trimmed = model.trim().replace(/^\/+/, "");
|
||||
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
|
||||
}
|
||||
|
||||
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
|
||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
|
||||
url.searchParams.set("key", client.apiKey);
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
||||
const config: Record<string, unknown> = {};
|
||||
if (params.temperature !== undefined) config.temperature = params.temperature;
|
||||
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
|
||||
return Object.keys(config).length ? config : undefined;
|
||||
}
|
||||
|
||||
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
||||
const input = schema as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
if (typeof input.type === "string") output.type = input.type;
|
||||
if (typeof input.description === "string") output.description = input.description;
|
||||
if (typeof input.format === "string") output.format = input.format;
|
||||
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
|
||||
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
|
||||
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
|
||||
|
||||
const items = toGeminiJsonSchema(input.items);
|
||||
if (items) output.items = items;
|
||||
|
||||
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input.properties)) {
|
||||
const propertySchema = toGeminiJsonSchema(value);
|
||||
if (propertySchema) properties[key] = propertySchema;
|
||||
}
|
||||
if (Object.keys(properties).length) output.properties = properties;
|
||||
}
|
||||
|
||||
return Object.keys(output).length ? output : undefined;
|
||||
}
|
||||
|
||||
function toGeminiTools(tools: any[]) {
|
||||
const functionDeclarations = tools
|
||||
.map((tool) => {
|
||||
if (tool?.type !== "function") return null;
|
||||
const declaration: Record<string, unknown> = {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
};
|
||||
const parameters = toGeminiJsonSchema(tool.function.parameters);
|
||||
if (parameters) declaration.parameters = parameters;
|
||||
return declaration;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
|
||||
}
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ text: message.content });
|
||||
}
|
||||
|
||||
return parts.length ? parts : [{ text: "" }];
|
||||
}
|
||||
|
||||
function buildConversationContent(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for Gemini.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: toContentParts(message),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseContents(messages: ChatMessage[]) {
|
||||
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
|
||||
}
|
||||
|
||||
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
|
||||
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
|
||||
return text ? { parts: [{ text }] } : undefined;
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
const normalized = normalizeUsage(usage);
|
||||
if (!normalized) return false;
|
||||
acc.inputTokens += normalized.inputTokens;
|
||||
acc.outputTokens += normalized.outputTokens;
|
||||
acc.totalTokens += normalized.totalTokens;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeUsage(usage: any) {
|
||||
if (!usage) return null;
|
||||
const inputTokens = usage.promptTokenCount ?? 0;
|
||||
const outputTokens = usage.candidatesTokenCount ?? 0;
|
||||
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
|
||||
return { inputTokens, outputTokens, totalTokens };
|
||||
}
|
||||
|
||||
function getCandidate(response: any) {
|
||||
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
|
||||
}
|
||||
|
||||
function getParts(response: any) {
|
||||
const parts = getCandidate(response)?.content?.parts;
|
||||
return Array.isArray(parts) ? parts : [];
|
||||
}
|
||||
|
||||
function extractText(response: any) {
|
||||
return getParts(response)
|
||||
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function stringifyToolArgs(args: unknown) {
|
||||
try {
|
||||
return JSON.stringify(args ?? {});
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
|
||||
return parts
|
||||
.filter((part) => part?.functionCall)
|
||||
.map((part, index) => ({
|
||||
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
|
||||
name: part.functionCall.name ?? "unknown_tool",
|
||||
arguments: stringifyToolArgs(part.functionCall.args),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
|
||||
return {
|
||||
functionResponse: {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
response: toolResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function appendCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "model", parts: [{ text }] });
|
||||
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
|
||||
}
|
||||
|
||||
async function parseGeminiResponse(response: Response) {
|
||||
const bodyText = await response.text();
|
||||
let body: any = null;
|
||||
try {
|
||||
body = bodyText ? JSON.parse(bodyText) : null;
|
||||
} catch {
|
||||
body = { raw: bodyText };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseGeminiResponse(response);
|
||||
}
|
||||
|
||||
function getFailureMessage(response: any, text: string, toolCallCount: number) {
|
||||
const promptBlockReason = response?.promptFeedback?.blockReason;
|
||||
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
|
||||
|
||||
const candidate = getCandidate(response);
|
||||
const finishReason = candidate?.finishReason;
|
||||
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
|
||||
if (text || toolCallCount > 0) return null;
|
||||
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
|
||||
}
|
||||
|
||||
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
|
||||
const tools = toGeminiTools(enabledTools);
|
||||
return {
|
||||
contents: conversation,
|
||||
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
|
||||
generationConfig: generationConfig(params),
|
||||
tools,
|
||||
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation = buildBaseContents(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await generateContent(params, buildRequest(params, conversation, enabledTools));
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
|
||||
|
||||
const parts = getParts(response);
|
||||
const text = extractText(response);
|
||||
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
|
||||
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({ role: "model", parts });
|
||||
|
||||
const toolResultParts: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({ role: "user", parts: toolResultParts });
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function findSseBoundary(buffer: string) {
|
||||
const crlf = buffer.indexOf("\r\n\r\n");
|
||||
const lf = buffer.indexOf("\n\n");
|
||||
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
||||
if (lf === -1) return { index: crlf, length: 4 };
|
||||
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
||||
}
|
||||
|
||||
function parseSseEvent(rawEvent: string) {
|
||||
const data = rawEvent
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart())
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!data || data === "[DONE]") return null;
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await parseGeminiResponse(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Gemini stream response did not include a body.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary = findSseBoundary(buffer);
|
||||
while (boundary) {
|
||||
const rawEvent = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const event = parseSseEvent(rawEvent);
|
||||
if (event) yield event;
|
||||
boundary = findSseBoundary(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
const event = parseSseEvent(tail);
|
||||
if (event) yield event;
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation = buildBaseContents(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
if (!enabledTools.length) {
|
||||
let text = "";
|
||||
let latestUsage: any = null;
|
||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
|
||||
rawResponses.push(response);
|
||||
if (response?.usageMetadata) latestUsage = response.usageMetadata;
|
||||
const failureMessage = getFailureMessage(response, extractText(response), 0);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
const delta = extractText(response);
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
|
||||
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const roundParts: any[] = [];
|
||||
let roundText = "";
|
||||
let latestRoundResponse: any = null;
|
||||
let latestRoundUsage: any = null;
|
||||
|
||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
|
||||
rawResponses.push(response);
|
||||
latestRoundResponse = response;
|
||||
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
|
||||
roundParts.push(...getParts(response));
|
||||
roundText += extractText(response);
|
||||
}
|
||||
|
||||
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
|
||||
|
||||
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
|
||||
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
|
||||
if (failureMessage) throw new Error(failureMessage);
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, "");
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({ role: "model", parts: roundParts });
|
||||
|
||||
const toolResultParts: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({ role: "user", parts: toolResultParts });
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: {
|
||||
streamed: true,
|
||||
responses: rawResponses,
|
||||
toolCallsUsed: totalToolCalls,
|
||||
toolCallLimitReached: true,
|
||||
api: "gemini.streamGenerateContent",
|
||||
},
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,45 +28,6 @@ import type { ChatMessage } from "../types.js";
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
|
||||
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
|
||||
|
||||
function readMaxTokens(value: unknown) {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
function getModelInfoMaxTokens(modelInfo: any) {
|
||||
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
|
||||
}
|
||||
|
||||
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
|
||||
if (params.maxTokens) return params.maxTokens;
|
||||
|
||||
const cached = modelMaxTokensCache.get(params.model);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
|
||||
|
||||
try {
|
||||
const retrieve = params.client?.models?.retrieve;
|
||||
if (typeof retrieve === "function") {
|
||||
const modelInfo = await retrieve.call(params.client.models, params.model);
|
||||
const maxTokens = getModelInfoMaxTokens(modelInfo);
|
||||
if (maxTokens) {
|
||||
modelMaxTokensCache.set(params.model, {
|
||||
maxTokens,
|
||||
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
|
||||
});
|
||||
return maxTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
|
||||
}
|
||||
|
||||
return DEFAULT_ANTHROPIC_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function toTools(tools: any[]) {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
@@ -199,12 +160,11 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
|
||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
} as any);
|
||||
@@ -232,7 +192,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
@@ -288,7 +248,6 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
||||
|
||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const maxTokens = await getMessagesMaxTokens(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -300,7 +259,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
stream: true,
|
||||
@@ -356,7 +315,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||
import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import type { ChatMessage, Provider } from "./types.js";
|
||||
|
||||
type ProviderAdapterParams = {
|
||||
@@ -28,7 +27,7 @@ export type ProviderChatAdapter = {
|
||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||
|
||||
type ChatProtocol = {
|
||||
id: ChatProtocolId;
|
||||
@@ -40,7 +39,6 @@ type ModelCatalogSpec = {
|
||||
enabled?: () => boolean;
|
||||
fetchModels(client: any): Promise<string[]>;
|
||||
fallbackModels?: () => string[];
|
||||
sortModels?: (models: string[]) => string[];
|
||||
};
|
||||
|
||||
type ProviderBackendSpec = {
|
||||
@@ -63,12 +61,6 @@ const messagesProtocol: ChatProtocol = {
|
||||
stream: streamWithMessagesApi,
|
||||
};
|
||||
|
||||
const geminiProtocol: ChatProtocol = {
|
||||
id: "gemini",
|
||||
complete: completeWithGeminiApi,
|
||||
stream: streamWithGeminiApi,
|
||||
};
|
||||
|
||||
const responsesProtocol: ChatProtocol = {
|
||||
id: "responses",
|
||||
complete: completeWithResponsesApi,
|
||||
@@ -85,48 +77,13 @@ function modelIdsFromListResponse(page: any) {
|
||||
: [];
|
||||
}
|
||||
|
||||
function stripModelResourcePrefix(model: string) {
|
||||
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||
}
|
||||
|
||||
function isLikelyResponsesApiModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function isLikelyGeminiChatModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (!id.startsWith("gemini-")) return false;
|
||||
if (id.includes("embedding") || id.includes("embed")) return false;
|
||||
if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false;
|
||||
if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false;
|
||||
if (id.includes("computer-use") || id.includes("robotics")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function preferGeminiModels(models: string[]) {
|
||||
const preferred = [
|
||||
"gemini-3.5-flash",
|
||||
"gemini-flash-latest",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-pro-latest",
|
||||
];
|
||||
const modelSet = new Set(models);
|
||||
return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))];
|
||||
}
|
||||
|
||||
async function fetchJson(url: URL): Promise<any> {
|
||||
const response = await fetch(url);
|
||||
const body: any = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`);
|
||||
}
|
||||
return body;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||
@@ -203,29 +160,6 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
createClient: geminiClient,
|
||||
plainProtocol: geminiProtocol,
|
||||
toolProtocol: geminiProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
||||
url.searchParams.set("key", client.apiKey);
|
||||
url.searchParams.set("pageSize", "1000");
|
||||
const page = await fetchJson(url);
|
||||
return Array.isArray(page?.models)
|
||||
? page.models
|
||||
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
||||
.map((model: any) => model?.name)
|
||||
.filter((id: unknown): id is string => typeof id === "string")
|
||||
.map(stripModelResourcePrefix)
|
||||
.filter(isLikelyGeminiChatModel)
|
||||
: [];
|
||||
},
|
||||
sortModels: preferGeminiModels,
|
||||
},
|
||||
},
|
||||
"hermes-agent": {
|
||||
createClient: hermesAgentClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
@@ -275,8 +209,7 @@ export function listModelCatalogProviders(): Provider[] {
|
||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||
const spec = backendSpecs[provider].modelCatalog;
|
||||
if (!spec) return [];
|
||||
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
return spec.sortModels ? spec.sortModels(models) : models;
|
||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
|
||||
@@ -6,7 +6,6 @@ const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
@@ -14,7 +13,6 @@ const prismaToApiProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
gemini: "gemini",
|
||||
hermes_agent: "hermes-agent",
|
||||
"hermes-agent": "hermes-agent",
|
||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export function openaiClient() {
|
||||
@@ -13,14 +13,6 @@ export function xaiClient() {
|
||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||
}
|
||||
|
||||
export function geminiClient() {
|
||||
if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set");
|
||||
return {
|
||||
apiKey: env.GEMINI_API_KEY,
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
};
|
||||
}
|
||||
|
||||
export function isHermesAgentConfigured() {
|
||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
||||
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
||||
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
||||
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
||||
|
||||
export type QuickQuestionRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
question: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export function buildQuickQuestionMultiplexRequest({
|
||||
question,
|
||||
...request
|
||||
}: QuickQuestionRequest): MultiplexRequest {
|
||||
return {
|
||||
...request,
|
||||
persist: false,
|
||||
messages: [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -119,12 +119,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
if (shouldPersist && chatId && call) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.message.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant" as any,
|
||||
content: text,
|
||||
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||
},
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
|
||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -33,7 +33,6 @@ export type ChatMessage = {
|
||||
export type MultiplexRequest = {
|
||||
chatId?: string;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
|
||||
+44
-361
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -11,17 +10,14 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
||||
import { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
import { exaClient } from "./search/exa.js";
|
||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||
import { searchWorkspace } from "./workspace-search.js";
|
||||
import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||
|
||||
type IncomingChatMessage = {
|
||||
@@ -92,13 +88,13 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||
}
|
||||
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
@@ -113,21 +109,14 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
||||
if (!toInsert.length) return;
|
||||
|
||||
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
|
||||
await prisma.message.createMany({
|
||||
data: toInsert.map((m, index) => {
|
||||
const metadata = {
|
||||
...(m.attachments?.length ? { attachments: m.attachments } : {}),
|
||||
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
|
||||
};
|
||||
return {
|
||||
chatId,
|
||||
role: m.role as any,
|
||||
content: m.content,
|
||||
name: m.name,
|
||||
metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
|
||||
};
|
||||
}),
|
||||
data: toInsert.map((m) => ({
|
||||
chatId,
|
||||
role: m.role as any,
|
||||
content: m.content,
|
||||
name: m.name,
|
||||
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,7 +169,6 @@ const CompletionStreamBody = z
|
||||
.object({
|
||||
chatId: z.string().optional(),
|
||||
persist: z.boolean().optional(),
|
||||
clientRequestId: z.string().trim().min(1).max(128).optional(),
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
@@ -198,25 +186,8 @@ const CompletionStreamBody = z
|
||||
path: ["chatId"],
|
||||
});
|
||||
}
|
||||
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "clientRequestId requires a persisted stream with chatId",
|
||||
path: ["clientRequestId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const QuickQuestionStreamBody = z.object({
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
question: z.string().trim().min(1),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||
if (!attachments?.length) return metadata as any;
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
@@ -331,21 +302,6 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
||||
}
|
||||
|
||||
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
|
||||
if (messageContent !== undefined) {
|
||||
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
|
||||
return `Fork of '${snippet}'`;
|
||||
}
|
||||
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
|
||||
}
|
||||
|
||||
export function copyForkMessageMetadata(metadata: unknown) {
|
||||
if (metadata === null || metadata === undefined) return undefined;
|
||||
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
||||
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
|
||||
return Object.keys(copied).length ? copied : undefined;
|
||||
}
|
||||
|
||||
async function generateChatTitle(content: string) {
|
||||
const systemPrompt =
|
||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||
@@ -443,8 +399,6 @@ function buildSseHeaders(originHeader: string | undefined) {
|
||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeChatStreamRequestIds = new Map<string, string>();
|
||||
const chatDeletionRoots = new Set<string>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
@@ -457,8 +411,6 @@ const starredProjectItemsSelect = {
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
titleGenerationPending: true,
|
||||
parentChatId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
@@ -539,29 +491,6 @@ async function getSearchSummary(searchId: string) {
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function listRecentChatsWithRoots() {
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
const includedIds = new Set(chats.map((chat) => chat.id));
|
||||
const missingRootIds = [
|
||||
...new Set(
|
||||
chats
|
||||
.map((chat) => chat.parentChatId)
|
||||
.filter((id): id is string => id !== null && !includedIds.has(id))
|
||||
),
|
||||
];
|
||||
if (!missingRootIds.length) return chats;
|
||||
|
||||
const roots = await prisma.chat.findMany({
|
||||
where: { id: { in: missingRootIds } },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return [...chats, ...roots].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
@@ -600,7 +529,11 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
listRecentChatsWithRoots(),
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
@@ -621,7 +554,6 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||
}
|
||||
|
||||
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return reply;
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
@@ -656,24 +588,10 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
||||
return { event: ev.type, data: ev };
|
||||
}
|
||||
|
||||
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||
const stream = new ActiveSseStream();
|
||||
activeChatStreams.set(chatId, stream);
|
||||
if (clientRequestId) {
|
||||
activeChatStreamRequestIds.set(chatId, clientRequestId);
|
||||
} else {
|
||||
activeChatStreamRequestIds.delete(chatId);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||
if (activeChatStreams.get(chatId) !== stream) return;
|
||||
activeChatStreams.delete(chatId);
|
||||
activeChatStreamRequestIds.delete(chatId);
|
||||
}
|
||||
|
||||
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
|
||||
void (async () => {
|
||||
let sawTerminalEvent = false;
|
||||
try {
|
||||
@@ -693,46 +611,11 @@ function executeActiveChatStream(chatId: string, body: z.infer<typeof Completion
|
||||
} catch (err) {
|
||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
} finally {
|
||||
clearActiveChatStream(chatId, stream);
|
||||
activeChatStreams.delete(chatId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function getMetadataClientRequestId(metadata: unknown) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||
return typeof clientRequestId === "string" ? clientRequestId : null;
|
||||
}
|
||||
|
||||
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
|
||||
const assistantMessages = await prisma.message.findMany({
|
||||
where: { chatId, role: "assistant" as any },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { content: true, metadata: true },
|
||||
});
|
||||
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
|
||||
}
|
||||
|
||||
function completeChatSubmissionStream(
|
||||
stream: ActiveSseStream,
|
||||
chatId: string,
|
||||
body: z.infer<typeof CompletionStreamBody>,
|
||||
assistantText: string
|
||||
) {
|
||||
stream.emit("meta", {
|
||||
type: "meta",
|
||||
chatId,
|
||||
callId: null,
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
});
|
||||
stream.complete({
|
||||
event: "done",
|
||||
data: {
|
||||
type: "done",
|
||||
text: assistantText,
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||
@@ -928,43 +811,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/v1/workspace-items", async (req) => {
|
||||
requireAdmin(req);
|
||||
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
|
||||
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
|
||||
if (parsed.data.q) {
|
||||
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
|
||||
select: {
|
||||
...chatSummarySelect,
|
||||
parentChat: { select: { projectItems: starredProjectItemsSelect } },
|
||||
},
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
const summaries = new Map<string, Record<string, unknown>>([
|
||||
...chats.map(({ parentChat, ...chat }) => [
|
||||
`chat:${chat.id}`,
|
||||
// Search omits unmatched roots, but sidebar fork stars belong to the root.
|
||||
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
|
||||
] as const),
|
||||
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
|
||||
]);
|
||||
const items = hits.flatMap((hit) => {
|
||||
const summary = summaries.get(`${hit.type}:${hit.id}`);
|
||||
return summary ? [{ ...summary, ...hit }] : [];
|
||||
});
|
||||
return { items, hasMore };
|
||||
}
|
||||
return { items: await listWorkspaceItems() };
|
||||
});
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const chats = await listRecentChatsWithRoots();
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
|
||||
@@ -1023,80 +879,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/fork", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const parsed = Body.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const source = await tx.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
parentChatId: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
userId: true,
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!source) return { status: "chat-not-found" as const };
|
||||
|
||||
let messages = source.messages;
|
||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||
if (parsed.data.messageId) {
|
||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||
if (selectedIndex < 0) return { status: "message-not-found" as const };
|
||||
selectedMessage = messages[selectedIndex];
|
||||
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
|
||||
messages = messages.slice(0, selectedIndex + 1);
|
||||
}
|
||||
|
||||
const forkMessageIdPrefix = `fork-${randomUUID()}`;
|
||||
const chat = await tx.chat.create({
|
||||
data: {
|
||||
title: buildForkTitle(source.title, selectedMessage?.content),
|
||||
titleGenerationPending: true,
|
||||
parentChatId: source.parentChatId ?? source.id,
|
||||
initiatedProvider: source.initiatedProvider,
|
||||
initiatedModel: source.initiatedModel,
|
||||
lastUsedProvider: source.lastUsedProvider,
|
||||
lastUsedModel: source.lastUsedModel,
|
||||
additionalSystemPrompt: source.additionalSystemPrompt,
|
||||
enabledTools: (source.enabledTools ?? undefined) as any,
|
||||
userId: source.userId,
|
||||
messages: messages.length
|
||||
? {
|
||||
create: messages.map((message, index) => ({
|
||||
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
|
||||
createdAt: message.createdAt,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
name: message.name,
|
||||
metadata: copyForkMessageMetadata(message.metadata) as any,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { status: "created" as const, chat };
|
||||
});
|
||||
|
||||
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
|
||||
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
|
||||
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
|
||||
return { chat: serializeChatLike(result.chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
@@ -1109,10 +891,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) {
|
||||
data.title = body.title;
|
||||
data.titleGenerationPending = false;
|
||||
}
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||
|
||||
@@ -1153,30 +932,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
let suggestedRaw = "";
|
||||
try {
|
||||
suggestedRaw = await generateChatTitle(body.content);
|
||||
} catch (err) {
|
||||
req.log.warn(
|
||||
{
|
||||
chatId: body.chatId,
|
||||
err: getErrorMessage(err),
|
||||
},
|
||||
"chat title generation failed; using fallback"
|
||||
);
|
||||
}
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: {
|
||||
id: body.chatId,
|
||||
title: existing.title,
|
||||
titleGenerationPending: existing.titleGenerationPending,
|
||||
},
|
||||
data: { title, titleGenerationPending: false },
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
data: { title },
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
@@ -1192,46 +956,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
req.log.info({ chatId }, "delete chat requested");
|
||||
|
||||
const target = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { parentChatId: true },
|
||||
});
|
||||
if (!target) {
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
req.log.warn({ chatId }, "delete chat target not found");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
const familyRootId = target.parentChatId ?? chatId;
|
||||
if (chatDeletionRoots.has(familyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
chatDeletionRoots.add(familyRootId);
|
||||
try {
|
||||
const familyIds = target.parentChatId
|
||||
? [chatId]
|
||||
: (
|
||||
await prisma.chat.findMany({
|
||||
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((chat) => chat.id);
|
||||
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
||||
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
||||
return app.httpErrors.conflict("chat or fork has an active stream");
|
||||
}
|
||||
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
req.log.warn({ chatId }, "delete chat target no longer exists");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
} finally {
|
||||
chatDeletionRoots.delete(familyRootId);
|
||||
}
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
app.get("/v1/searches", async (req) => {
|
||||
@@ -1521,7 +1253,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
@@ -1607,28 +1339,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
// One-shot, non-persistent Quick Question SSE endpoint.
|
||||
app.post("/v1/quick-questions/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
|
||||
const parsed = QuickQuestionStreamBody.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
|
||||
// General chat completion SSE endpoint.
|
||||
// Streaming SSE endpoint.
|
||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
|
||||
@@ -1636,51 +1347,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// Ensure the chat exists and identify its family before reserving a stream.
|
||||
let chatFamilyRootId: string | null = null;
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
const exists = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: { id: true, parentChatId: true },
|
||||
});
|
||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
||||
}
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.persist !== false && body.chatId) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
if (body.persist !== false && body.chatId) {
|
||||
const activeStream = activeChatStreams.get(body.chatId);
|
||||
if (activeStream) {
|
||||
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
||||
return streamActiveRun(req, reply, activeStream);
|
||||
}
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
|
||||
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||
try {
|
||||
if (body.clientRequestId) {
|
||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||
if (completedSubmission) {
|
||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||
const configuredBody = await applyStoredChatSettings(body);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
} catch (err) {
|
||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
throw err;
|
||||
}
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
||||
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
||||
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
||||
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
||||
const MAX_RATE_LIMIT_RETRIES = 3;
|
||||
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
||||
|
||||
type RateLimitPolicy = {
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
};
|
||||
|
||||
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||
let lastRequestAtMs = 0;
|
||||
let nextRequestAtMs = 0;
|
||||
let quotaUnavailableUntilMs = 0;
|
||||
let requestQueue = Promise.resolve();
|
||||
|
||||
export type BraveSearchOptions = {
|
||||
numResults: number;
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
};
|
||||
|
||||
export type BraveSearchResult = {
|
||||
title: string | null;
|
||||
url: string | null;
|
||||
publishedDate: string | null;
|
||||
author: string | null;
|
||||
summary: string | null;
|
||||
text: string | null;
|
||||
highlights: string[];
|
||||
};
|
||||
|
||||
export type BraveSearchResponse = {
|
||||
query: string;
|
||||
requestId: string | null;
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
|
||||
function clipText(input: string, maxCharacters: number) {
|
||||
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
||||
}
|
||||
|
||||
function compactWhitespace(input: string) {
|
||||
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function requireBraveSearchApiKey() {
|
||||
if (!env.BRAVE_SEARCH_API_KEY) {
|
||||
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
||||
}
|
||||
return env.BRAVE_SEARCH_API_KEY;
|
||||
}
|
||||
|
||||
function sleep(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function addIntervalSafety(intervalMs: number) {
|
||||
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
||||
}
|
||||
|
||||
function parseCommaSeparatedNumbers(value: string | null) {
|
||||
if (!value) return [];
|
||||
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
||||
}
|
||||
|
||||
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
||||
if (!value) return [];
|
||||
return value.split(",").flatMap((part) => {
|
||||
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
||||
if (!match) return [];
|
||||
const limit = Number(match[1]);
|
||||
const windowSeconds = Number(match[2]);
|
||||
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
||||
if (!policies.length) return null;
|
||||
let burstIndex = 0;
|
||||
for (let index = 1; index < policies.length; index += 1) {
|
||||
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
||||
}
|
||||
return burstIndex;
|
||||
}
|
||||
|
||||
function updateRateLimitState(headers: Headers) {
|
||||
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
||||
const burstIndex = getBurstPolicyIndex(policies);
|
||||
if (burstIndex === null) return;
|
||||
|
||||
const burstPolicy = policies[burstIndex]!;
|
||||
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
||||
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
||||
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
||||
}
|
||||
requestIntervalMs = learnedIntervalMs;
|
||||
|
||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||
for (let index = 0; index < policies.length; index += 1) {
|
||||
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
||||
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
||||
if (index === burstIndex) {
|
||||
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
||||
} else {
|
||||
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertLongTermQuotaAvailable() {
|
||||
if (quotaUnavailableUntilMs <= Date.now()) {
|
||||
quotaUnavailableUntilMs = 0;
|
||||
return;
|
||||
}
|
||||
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
||||
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
||||
}
|
||||
|
||||
async function waitForRateLimitSlot() {
|
||||
const reservation = requestQueue.then(async () => {
|
||||
while (true) {
|
||||
assertLongTermQuotaAvailable();
|
||||
const waitMs = nextRequestAtMs - Date.now();
|
||||
if (waitMs <= 0) break;
|
||||
await sleep(waitMs);
|
||||
}
|
||||
lastRequestAtMs = Date.now();
|
||||
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
||||
});
|
||||
requestQueue = reservation.catch(() => undefined);
|
||||
await reservation;
|
||||
}
|
||||
|
||||
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
||||
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
||||
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
||||
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
||||
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
||||
}
|
||||
|
||||
async function fetchBrave(url: URL) {
|
||||
const apiKey = requireBraveSearchApiKey();
|
||||
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
||||
await waitForRateLimitSlot();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...buildBrowserLikeRequestHeaders("application/json"),
|
||||
"X-Subscription-Token": apiKey,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
updateRateLimitState(response.headers);
|
||||
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
||||
|
||||
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
||||
await response.arrayBuffer();
|
||||
if (retryDelayMs === null) {
|
||||
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
||||
}
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
|
||||
throw new Error("Brave Search API request failed after rate-limit retries.");
|
||||
}
|
||||
|
||||
function normalizeDomain(input: string) {
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||
return parsed.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomains(input: string[] | undefined) {
|
||||
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
||||
}
|
||||
|
||||
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
||||
if (!urlRaw) return false;
|
||||
try {
|
||||
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
||||
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
||||
const includeDomains = normalizeDomains(options.includeDomains);
|
||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||
return results.filter((result) => {
|
||||
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
||||
const includeDomains = normalizeDomains(options.includeDomains);
|
||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||
const includeClause =
|
||||
includeDomains.length === 0
|
||||
? ""
|
||||
: includeDomains.length === 1
|
||||
? `site:${includeDomains[0]}`
|
||||
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
||||
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
||||
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
||||
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
||||
url.searchParams.set("q", buildBraveQuery(query, options));
|
||||
url.searchParams.set("count", String(options.numResults));
|
||||
url.searchParams.set("safesearch", "moderate");
|
||||
url.searchParams.set("result_filter", "web");
|
||||
url.searchParams.set("text_decorations", "false");
|
||||
url.searchParams.set("extra_snippets", "true");
|
||||
return url;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = compactWhitespace(value);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
||||
}
|
||||
|
||||
function mapWebResult(result: any): BraveSearchResult {
|
||||
const description = stringOrNull(result?.description);
|
||||
const extraSnippets = stringArray(result?.extra_snippets);
|
||||
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
||||
const combinedText = snippets.join("\n\n");
|
||||
|
||||
return {
|
||||
title: stringOrNull(result?.title),
|
||||
url: stringOrNull(result?.url),
|
||||
publishedDate: stringOrNull(result?.page_age),
|
||||
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
||||
summary: description ? clipText(description, 1_400) : null,
|
||||
text: combinedText ? clipText(combinedText, 700) : null,
|
||||
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
||||
const url = buildSearchUrl(query, options);
|
||||
const response = await fetchBrave(url);
|
||||
|
||||
if (!response.ok) {
|
||||
await response.arrayBuffer();
|
||||
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
await response.arrayBuffer();
|
||||
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
||||
return {
|
||||
query,
|
||||
requestId: response.headers.get("x-request-id"),
|
||||
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetBraveRateLimitStateForTests() {
|
||||
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||
lastRequestAtMs = 0;
|
||||
nextRequestAtMs = 0;
|
||||
quotaUnavailableUntilMs = 0;
|
||||
requestQueue = Promise.resolve();
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./db.js";
|
||||
|
||||
export const WORKSPACE_SEARCH_LIMIT = 100;
|
||||
|
||||
// Treat input as literal Unicode words, never FTS operators/column expressions.
|
||||
// Prefix matching supports type-ahead, including short terms such as "AI".
|
||||
export function workspaceSearchExpression(query: string): string | null {
|
||||
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
|
||||
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
|
||||
}
|
||||
|
||||
export type WorkspaceSearchHit = {
|
||||
type: "chat" | "search";
|
||||
id: string;
|
||||
searchSnippet: string | null;
|
||||
};
|
||||
|
||||
export function workspaceSearchSQL(expression: string) {
|
||||
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
|
||||
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
|
||||
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
|
||||
return Prisma.sql`
|
||||
WITH chat_hits AS (
|
||||
SELECT c.id, NULL AS message_rowid
|
||||
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
|
||||
WHERE "ChatSearch" MATCH ${expression}
|
||||
UNION ALL
|
||||
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
|
||||
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
GROUP BY m.chatId
|
||||
), matches AS (
|
||||
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
|
||||
NULL AS search_rowid
|
||||
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
|
||||
GROUP BY c.id
|
||||
UNION ALL
|
||||
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
|
||||
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
), limited AS MATERIALIZED (
|
||||
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
|
||||
), message_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "MessageSearch"
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT message_rowid FROM limited)
|
||||
), search_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "SavedSearchSearch"
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT search_rowid FROM limited)
|
||||
)
|
||||
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
|
||||
FROM limited
|
||||
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
|
||||
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
|
||||
ORDER BY updatedAt DESC, type, id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function searchWorkspace(query: string) {
|
||||
const expression = workspaceSearchExpression(query);
|
||||
if (!expression) return { hits: [], hasMore: false };
|
||||
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
|
||||
// only for the bounded result set, never fetch full transcripts into JS.
|
||||
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||||
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
|
||||
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
|
||||
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
|
||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
|
||||
const bodies: any[] = [];
|
||||
let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0;
|
||||
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
|
||||
const source=createServer(async(req,res)=>{
|
||||
assert.equal(req.headers.authorization,`Bearer ${key}`);
|
||||
const chunks: Buffer[]=[];
|
||||
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
|
||||
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
|
||||
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;}
|
||||
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
|
||||
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
|
||||
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
|
||||
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
|
||||
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
|
||||
});
|
||||
source.listen(0,'127.0.0.1');await once(source,'listening');
|
||||
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
|
||||
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
|
||||
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));});
|
||||
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
|
||||
|
||||
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
|
||||
const before=uploadCalls;
|
||||
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
|
||||
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
|
||||
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
|
||||
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
|
||||
});
|
||||
|
||||
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
|
||||
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
|
||||
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
|
||||
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
|
||||
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
|
||||
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
|
||||
});
|
||||
|
||||
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
|
||||
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
|
||||
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
|
||||
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
|
||||
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
|
||||
});
|
||||
|
||||
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
|
||||
const before=uploadCalls;
|
||||
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
|
||||
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
|
||||
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
|
||||
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
|
||||
});
|
||||
|
||||
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
|
||||
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
|
||||
const before=uploadCalls;
|
||||
holdDescriptor=true;
|
||||
for(let n=0;n<3;n++){
|
||||
const expected=held.length+1,terminal=closed.length+1;
|
||||
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
|
||||
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
|
||||
}
|
||||
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
|
||||
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
|
||||
holdDownload=true;
|
||||
for(let n=0;n<5;n++){
|
||||
const expected=held.length+1,terminal=closed.length+1;
|
||||
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
|
||||
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
|
||||
}
|
||||
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { createConnection } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
});
|
||||
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
|
||||
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
|
||||
]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
|
||||
const privateKey = 'sybil-agent-private-key-for-test';
|
||||
const requests: { method?: string; url?: string; body: any }[] = [];
|
||||
const accepted = new Map<string, number>();
|
||||
let upstreamReply: { status: number; body: string } | undefined;
|
||||
let connections = 0;
|
||||
const subscriptions: any[] = [];
|
||||
const upstream = createServer(async (req, res) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||
requests.push({ method: req.method, url: req.url, body });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
|
||||
if (req.method === 'POST') {
|
||||
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
|
||||
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
|
||||
} else if (req.url?.includes('/messages')) {
|
||||
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
|
||||
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
|
||||
});
|
||||
const sourceSockets = new WebSocketServer({ noServer: true });
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||
assert.equal(req.url, '/api/ws');
|
||||
connections++;
|
||||
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
||||
ws.on('message', raw => {
|
||||
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
|
||||
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
|
||||
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
|
||||
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
|
||||
});
|
||||
});
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address() as { port: number };
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const address = app.server.address() as { port: number };
|
||||
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
for (const socket of sourceSockets.clients) socket.terminate();
|
||||
sourceSockets.close();
|
||||
await new Promise<void>(resolve => upstream.close(() => resolve()));
|
||||
await prisma.$disconnect();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function eventually(check: () => boolean) {
|
||||
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
|
||||
assert.fail('Expected condition was not reached');
|
||||
}
|
||||
|
||||
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
|
||||
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
|
||||
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
|
||||
assert.equal(descriptor.statusCode, 200);
|
||||
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
|
||||
assert.ok(!descriptor.body.includes(privateKey));
|
||||
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
|
||||
assert.equal(page.json().messages[0].role, 'assistant');
|
||||
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
|
||||
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
|
||||
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
|
||||
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
|
||||
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
|
||||
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
|
||||
assert.equal(initial.json().cursor, '0');
|
||||
assert.equal(initial.headers['cache-control'], 'no-store');
|
||||
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
|
||||
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
|
||||
await prisma.$disconnect();
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
|
||||
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
|
||||
try {
|
||||
upstreamReply = { status: 200, body: '{truncated' };
|
||||
for (const url of ['/v1/agent', '/v1/agent/messages']) {
|
||||
const response = await app.inject({ url, headers: auth });
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
|
||||
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
|
||||
upstreamReply = { status: 202, body };
|
||||
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(response.statusCode, 502, body);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
} finally { upstreamReply = undefined; }
|
||||
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
|
||||
assert.equal(retry.statusCode, 202);
|
||||
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
|
||||
});
|
||||
|
||||
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
|
||||
const before = connections;
|
||||
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
let response = '';
|
||||
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
|
||||
});
|
||||
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
|
||||
socket.on('data', chunk => { response += chunk.toString(); });
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => { socket.destroy(); resolve(response); });
|
||||
});
|
||||
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
|
||||
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
|
||||
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
|
||||
assert.equal(connections, before);
|
||||
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
|
||||
const before = connections;
|
||||
const denied = new WebSocket(wsUrl); await once(denied, 'open');
|
||||
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
|
||||
const [code] = await once(denied, 'close');
|
||||
assert.equal(code, 4401); assert.equal(connections, before);
|
||||
const browser = new WebSocket(wsUrl);
|
||||
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'activity'));
|
||||
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
browser.close(); await once(browser, 'close');
|
||||
await eventually(() => sourceSockets.clients.size === 0);
|
||||
});
|
||||
|
||||
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try {
|
||||
const browser = new WebSocket(wsUrl); const frames: any[] = [];
|
||||
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
|
||||
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
|
||||
await eventually(() => frames.some(frame => frame.type === 'ready'));
|
||||
browser.close(); await once(browser, 'close');
|
||||
} finally { env.ADMIN_TOKEN = saved; }
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { env } from "../src/env.js";
|
||||
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
|
||||
|
||||
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: " Brave result ",
|
||||
url: "https://docs.example.com/article",
|
||||
description: "Main\n snippet",
|
||||
extra_snippets: ["Extra snippet one", "Extra snippet two"],
|
||||
page_age: "2026-07-18T12:00:00Z",
|
||||
profile: { name: "Example Docs" },
|
||||
},
|
||||
{
|
||||
title: "Excluded result",
|
||||
url: "https://blocked.example.com/article",
|
||||
description: "Should be filtered",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-request-id": "brave-request-1",
|
||||
},
|
||||
}
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await searchBrave("latest docs", {
|
||||
numResults: 5,
|
||||
includeDomains: ["https://example.com/path"],
|
||||
excludeDomains: ["blocked.example.com"],
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
const requestUrl = new URL(String(fetchCalls[0]?.input));
|
||||
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
|
||||
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
|
||||
assert.equal(requestUrl.searchParams.get("count"), "5");
|
||||
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
|
||||
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
|
||||
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
|
||||
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
|
||||
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
|
||||
|
||||
assert.deepEqual(response, {
|
||||
query: "latest docs",
|
||||
requestId: "brave-request-1",
|
||||
results: [
|
||||
{
|
||||
title: "Brave result",
|
||||
url: "https://docs.example.com/article",
|
||||
publishedDate: "2026-07-18T12:00:00Z",
|
||||
author: "Example Docs",
|
||||
summary: "Main snippet",
|
||||
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
|
||||
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave rejects requests without an API key", async () => {
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = undefined;
|
||||
try {
|
||||
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
|
||||
} finally {
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave reports non-JSON responses", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("upstream error", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => searchBrave("test", { numResults: 1 }),
|
||||
/Brave Search API returned text\/plain/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const requestStartedAt: number[] = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
requestStartedAt.push(Date.now());
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "1, 1999",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
searchBrave("burst one", { numResults: 1 }),
|
||||
searchBrave("burst two", { numResults: 1 }),
|
||||
searchBrave("burst three", { numResults: 1 }),
|
||||
]);
|
||||
|
||||
assert.equal(requestStartedAt.length, 3);
|
||||
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
|
||||
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
const requestStartedAt: number[] = [];
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
requestStartedAt.push(Date.now());
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
|
||||
"x-ratelimit-remaining": "49, 0",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await searchBrave("learn upgraded policy", { numResults: 1 });
|
||||
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
|
||||
|
||||
assert.equal(requestStartedAt.length, 9);
|
||||
const burstStartedAt = requestStartedAt.slice(1);
|
||||
for (let index = 1; index < burstStartedAt.length; index += 1) {
|
||||
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
|
||||
}
|
||||
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave retries 429 responses after the burst window resets", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
let fetchCount = 0;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
const rateLimitHeaders = {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
|
||||
"x-ratelimit-reset": "1, 2678400",
|
||||
};
|
||||
if (fetchCount === 1) {
|
||||
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
|
||||
status: 429,
|
||||
headers: rateLimitHeaders,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await searchBrave("retry burst", { numResults: 1 });
|
||||
assert.equal(fetchCount, 2);
|
||||
assert.ok(Date.now() - startedAt >= 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave does not wait for exhausted long-term quotas", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "0, 0",
|
||||
"x-ratelimit-reset": "1, 100000",
|
||||
},
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
() => searchBrave("quota exhausted", { numResults: 1 }),
|
||||
/rate limit quota is exhausted beyond the retry window/
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 1_000);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||
let fetchCount = 0;
|
||||
resetBraveRateLimitStateForTests();
|
||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||
"x-ratelimit-remaining": "0, 0",
|
||||
"x-ratelimit-reset": "1, 100000",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await searchBrave("last allowed query", { numResults: 1 });
|
||||
await assert.rejects(
|
||||
() => searchBrave("over quota query", { numResults: 1 }),
|
||||
/long-term quota is exhausted/
|
||||
);
|
||||
assert.equal(fetchCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||
resetBraveRateLimitStateForTests();
|
||||
}
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import sensible from "@fastify/sensible";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
||||
cwd: serverRoot,
|
||||
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
|
||||
import("../src/routes.js"),
|
||||
import("../src/db.js"),
|
||||
]);
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("forks copy bounded history and keep every child grouped under the root", async () => {
|
||||
const timestamp = new Date("2026-08-16T12:00:00.000Z");
|
||||
const source = await prisma.chat.create({
|
||||
data: {
|
||||
title: "Planning session",
|
||||
initiatedProvider: "openai",
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: "anthropic",
|
||||
lastUsedModel: "claude-sonnet-4-20250514",
|
||||
additionalSystemPrompt: "Keep answers concise.",
|
||||
enabledTools: ["web_search"],
|
||||
messages: {
|
||||
create: [
|
||||
{
|
||||
id: "source-message-0001",
|
||||
createdAt: timestamp,
|
||||
role: "user",
|
||||
content: "Plan a trip",
|
||||
metadata: {
|
||||
clientRequestId: "source-request",
|
||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source-message-0002",
|
||||
createdAt: timestamp,
|
||||
role: "assistant",
|
||||
content: "First assistant response",
|
||||
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
|
||||
},
|
||||
{
|
||||
id: "source-message-0003",
|
||||
createdAt: timestamp,
|
||||
role: "user",
|
||||
content: "Only visible in a whole-chat fork",
|
||||
},
|
||||
],
|
||||
},
|
||||
calls: {
|
||||
create: {
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
request: { input: "Plan a trip" },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
|
||||
const forkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: source.messages[1].id },
|
||||
});
|
||||
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
|
||||
const forkSummary = forkResponse.json().chat;
|
||||
assert.equal(forkSummary.parentChatId, source.id);
|
||||
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
|
||||
assert.equal(forkSummary.titleGenerationPending, true);
|
||||
assert.equal(forkSummary.starred, false);
|
||||
|
||||
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
|
||||
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
|
||||
const fork = detailResponse.json().chat;
|
||||
assert.deepEqual(
|
||||
fork.messages.map((message: any) => [message.role, message.content]),
|
||||
[
|
||||
["user", "Plan a trip"],
|
||||
["assistant", "First assistant response"],
|
||||
]
|
||||
);
|
||||
assert.notEqual(fork.messages[0].id, source.messages[0].id);
|
||||
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
|
||||
assert.deepEqual(fork.messages[0].metadata, {
|
||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||
});
|
||||
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
|
||||
assert.equal(fork.initiatedProvider, "openai");
|
||||
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
|
||||
assert.equal(fork.lastUsedProvider, "anthropic");
|
||||
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
|
||||
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
|
||||
assert.deepEqual(fork.enabledTools, ["web_search"]);
|
||||
assert.deepEqual(fork.calls, []);
|
||||
|
||||
const childForkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${forkSummary.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
|
||||
const childFork = childForkResponse.json().chat;
|
||||
assert.equal(childFork.parentChatId, source.id);
|
||||
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
|
||||
assert.equal(childFork.titleGenerationPending, true);
|
||||
|
||||
const wholeForkResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
|
||||
const wholeFork = wholeForkResponse.json().chat;
|
||||
assert.equal(wholeFork.parentChatId, source.id);
|
||||
assert.equal(wholeFork.title, "Fork of Planning session");
|
||||
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
|
||||
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
|
||||
assert.deepEqual(
|
||||
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
|
||||
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
|
||||
);
|
||||
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
|
||||
|
||||
const suggestedTitleResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/chats/title/suggest",
|
||||
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
|
||||
});
|
||||
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
|
||||
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
|
||||
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
|
||||
|
||||
const repeatedTitleResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/chats/title/suggest",
|
||||
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
|
||||
});
|
||||
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
|
||||
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
|
||||
|
||||
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
|
||||
where: { id: source.id },
|
||||
include: { messages: true, calls: true },
|
||||
});
|
||||
assert.equal(sourceAfterForks.messages.length, 3);
|
||||
assert.equal(sourceAfterForks.calls.length, 1);
|
||||
|
||||
const manualTitleResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/v1/chats/${forkSummary.id}`,
|
||||
payload: { title: "Manual branch title" },
|
||||
});
|
||||
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
|
||||
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
|
||||
|
||||
const activeForkStream = registerActiveChatStream(wholeFork.id);
|
||||
try {
|
||||
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
|
||||
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
|
||||
|
||||
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
|
||||
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
|
||||
} finally {
|
||||
clearActiveChatStream(wholeFork.id, activeForkStream);
|
||||
}
|
||||
|
||||
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
|
||||
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
|
||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
|
||||
|
||||
const unrelated = await prisma.chat.create({
|
||||
data: {
|
||||
messages: { create: { role: "assistant", content: "Unrelated response" } },
|
||||
},
|
||||
include: { messages: true },
|
||||
});
|
||||
const countBeforeInvalidForks = await prisma.chat.count();
|
||||
|
||||
const wrongChatResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: unrelated.messages[0].id },
|
||||
});
|
||||
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
|
||||
assert.equal(wrongChatResponse.json().message, "message not found in chat");
|
||||
|
||||
const nonAssistantResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${source.id}/fork`,
|
||||
payload: { messageId: source.messages[0].id },
|
||||
});
|
||||
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
|
||||
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
|
||||
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
|
||||
|
||||
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
|
||||
const concurrentDeleteResponses = await Promise.all([
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||
]);
|
||||
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
|
||||
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||
|
||||
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
|
||||
const concurrentChildResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/chats/${concurrentRoot.id}/fork`,
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
|
||||
const concurrentChild = concurrentChildResponse.json().chat;
|
||||
const concurrentFamilyDeleteResponses = await Promise.all([
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
|
||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
|
||||
]);
|
||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
|
||||
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
|
||||
|
||||
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
|
||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
|
||||
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
|
||||
});
|
||||
@@ -140,94 +140,6 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||
});
|
||||
|
||||
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
|
||||
let requestBody: any = null;
|
||||
let retrievedModel: string | null = null;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async (model: string) => {
|
||||
retrievedModel = model;
|
||||
return { id: model, max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return {
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-max-default-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
});
|
||||
|
||||
assert.equal(retrievedModel, "claude-max-default-test");
|
||||
assert.equal(requestBody?.max_tokens, 128000);
|
||||
assert.equal(result.text, "Done");
|
||||
});
|
||||
|
||||
test("Messages API preserves explicit maxTokens", async () => {
|
||||
let requestBody: any = null;
|
||||
let didRetrieveModel = false;
|
||||
const client = {
|
||||
models: {
|
||||
retrieve: async () => {
|
||||
didRetrieveModel = true;
|
||||
return { max_tokens: 128000 };
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBody = body;
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-explicit-max-test",
|
||||
messages: [{ role: "user", content: "Say done" }],
|
||||
maxTokens: 4096,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(didRetrieveModel, false);
|
||||
assert.equal(requestBody?.max_tokens, 4096);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("fetch_url sends browser-like navigation headers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { env } from "../src/env.js";
|
||||
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||
|
||||
for (const { name, listedModels, expectedModels } of [
|
||||
{
|
||||
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||
listedModels: [
|
||||
"gpt-4.1-mini",
|
||||
"chat-latest",
|
||||
"o3",
|
||||
"chatgpt-4o-latest",
|
||||
"chat-latest",
|
||||
"text-embedding-3-small",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime",
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-tts",
|
||||
"gpt-image-1",
|
||||
"chatgpt-image-latest",
|
||||
"gpt-4o-search-preview",
|
||||
"computer-use-preview",
|
||||
"sora-2",
|
||||
],
|
||||
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||
listedModels: ["gpt-4.1-mini"],
|
||||
expectedModels: ["gpt-4.1-mini"],
|
||||
},
|
||||
]) {
|
||||
test(name, async (t) => {
|
||||
const originalKey = env.OPENAI_API_KEY;
|
||||
env.OPENAI_API_KEY = "test-openai-key";
|
||||
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.equal(url, "https://api.openai.com/v1/models");
|
||||
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||
});
|
||||
}
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
@@ -71,12 +27,6 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), {
|
||||
provider: "gemini",
|
||||
protocol: "gemini",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||
provider: "hermes-agent",
|
||||
protocol: "chat-completions",
|
||||
|
||||
@@ -5,10 +5,8 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||
assert.equal(toPrismaProvider("gemini"), "gemini");
|
||||
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||
initiatedProvider: "hermes-agent",
|
||||
lastUsedProvider: "gemini",
|
||||
lastUsedProvider: "xai",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildQuickQuestionMultiplexRequest,
|
||||
QUICK_QUESTION_SYSTEM_PROMPT,
|
||||
} from "../src/llm/quick-question.js";
|
||||
|
||||
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
||||
const request = buildQuickQuestionMultiplexRequest({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
question: "How do I reset my password?",
|
||||
enabledTools: ["web_search"],
|
||||
userLocation: "Los Angeles, CA",
|
||||
});
|
||||
|
||||
assert.equal(request.persist, false);
|
||||
assert.equal(request.chatId, undefined);
|
||||
assert.deepEqual(request.messages, [
|
||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user", content: "How do I reset my password?" },
|
||||
]);
|
||||
assert.deepEqual(request.enabledTools, ["web_search"]);
|
||||
assert.equal(request.userLocation, "Los Angeles, CA");
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
|
||||
const migration = "20261007000000_add_workspace_full_text_search";
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
|
||||
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
|
||||
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
|
||||
}
|
||||
function migrate() {
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
|
||||
cwd: serverRoot, env: process.env, stdio: "pipe",
|
||||
});
|
||||
}
|
||||
migrate();
|
||||
const { prisma } = await import("../src/db.js");
|
||||
const oldChat = await prisma.chat.create({ data: {
|
||||
title: "Old constellation notes",
|
||||
messages: { create: [
|
||||
{ role: "user", content: "We photographed a nebula before the migration." },
|
||||
{ role: "system", content: "secretmigrationprompt" },
|
||||
] },
|
||||
} });
|
||||
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
|
||||
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
|
||||
migrate();
|
||||
|
||||
const { registerRoutes } = await import("../src/routes.js");
|
||||
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
async function search(query: string) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
await prisma.chat.deleteMany();
|
||||
await prisma.search.deleteMany();
|
||||
});
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration indexes existing titles, messages, queries, and answers", async () => {
|
||||
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
|
||||
assert.equal((await search("constellation")).items.length, 2);
|
||||
assert.equal((await search("secretmigrationprompt")).items.length, 0);
|
||||
});
|
||||
|
||||
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "Travel notes", updatedAt: new Date("2000-01-01"),
|
||||
messages: { create: [
|
||||
{ role: "user", content: "Let's discuss café planning in Zürich" },
|
||||
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
|
||||
{ role: "system", content: "hiddensystemvalue" },
|
||||
{ role: "tool", content: "hiddentoolvalue" },
|
||||
] },
|
||||
} });
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
|
||||
const result = await search("CAFE plan");
|
||||
assert.equal(result.hasMore, false);
|
||||
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
|
||||
assert.match(result.items[0].searchSnippet, /café planning/i);
|
||||
assert.equal(result.items[0].messages, undefined);
|
||||
assert.equal((await search("zuri")).items.length, 1);
|
||||
assert.equal((await search("hiddensystemvalue")).items.length, 0);
|
||||
assert.equal((await search("hiddentoolvalue")).items.length, 0);
|
||||
});
|
||||
|
||||
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
|
||||
}, include: { messages: true } });
|
||||
const messageID = chat.messages[0].id;
|
||||
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
|
||||
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
|
||||
assert.equal((await search("original")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 0);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
|
||||
throw new Error("rollback");
|
||||
}));
|
||||
assert.equal((await search("rolledbackvalue")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
|
||||
assert.equal(fork.statusCode, 200, fork.body);
|
||||
assert.equal((await search("replacementbody")).items.length, 2);
|
||||
await prisma.message.delete({ where: { id: messageID } });
|
||||
const onlyFork = (await search("replacementbody")).items;
|
||||
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
|
||||
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
|
||||
assert.equal(starResponse.statusCode, 200, starResponse.body);
|
||||
const starredFork = (await search("replacementbody")).items[0];
|
||||
assert.equal(starredFork.starred, true);
|
||||
assert.ok(starredFork.starredAt);
|
||||
assert.equal(starredFork.parentChat, undefined);
|
||||
await prisma.chat.delete({ where: { id: chat.id } });
|
||||
assert.equal((await search("replacement")).items.length, 0);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("saved searches match query and answer, and update/delete their index", async () => {
|
||||
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
|
||||
assert.equal((await search("ai des")).items[0].type, "search");
|
||||
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
|
||||
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
|
||||
assert.equal((await search("nebula")).items.length, 0);
|
||||
assert.equal((await search("design")).items.length, 0);
|
||||
assert.equal((await search("Bookmarks")).items.length, 0);
|
||||
assert.equal((await search("newanswer")).items.length, 1);
|
||||
await prisma.search.delete({ where: { id: saved.id } });
|
||||
assert.equal((await search("new")).items.length, 0);
|
||||
});
|
||||
|
||||
test("literal queries, validation, blank queries, and bounded snippets", async () => {
|
||||
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
|
||||
assert.equal((await search('"AI" OR safety*')).items.length, 1);
|
||||
assert.equal((await search("AI OR other")).items.length, 0);
|
||||
assert.equal((await search("AI")).items.length, 1);
|
||||
assert.equal((await search("*** : () --")).items.length, 0);
|
||||
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
|
||||
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.deepEqual(await search(" "), recent);
|
||||
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
|
||||
assert.equal(response.statusCode, 400, response.body);
|
||||
}
|
||||
});
|
||||
|
||||
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
|
||||
const date = new Date("2026-01-01");
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
|
||||
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
|
||||
const result = await search("common");
|
||||
assert.equal(result.items.length, 100);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.items[0].id, "saved");
|
||||
assert.equal(result.items[1].id, "chat-000");
|
||||
assert.equal(result.items.at(-1).id, "chat-098");
|
||||
});
|
||||
|
||||
test("query plan uses all three FTS indexes", async () => {
|
||||
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
|
||||
}
|
||||
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
|
||||
});
|
||||
|
||||
test("vacuum preserves external-content index row identities", async () => {
|
||||
const removed = await prisma.chat.create({ data: { title: "removed" } });
|
||||
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
|
||||
await prisma.chat.delete({ where: { id: removed.id } });
|
||||
await prisma.$executeRaw`VACUUM`;
|
||||
assert.equal((await search("kepttitle")).items[0].id, kept.id);
|
||||
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
|
||||
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
+22
-45
@@ -42,13 +42,12 @@ type ToolLogMetadata = {
|
||||
resultPreview?: string | null;
|
||||
};
|
||||
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
"hermes-agent": ["hermes-agent"],
|
||||
};
|
||||
|
||||
@@ -56,7 +55,6 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
anthropic: { models: [], loadedAt: null, error: null },
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
gemini: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
|
||||
function escapeTags(value: string) {
|
||||
@@ -81,7 +79,6 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "gemini") return "Gemini";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
@@ -127,27 +124,21 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
||||
}
|
||||
|
||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
const chatsById = new Map<string, ChatSummary>();
|
||||
for (const item of items) {
|
||||
if (item.type === "chat") chatsById.set(item.id, item);
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.type === "chat") {
|
||||
const chat = item;
|
||||
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
|
||||
return {
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: starOwner.starred,
|
||||
starredAt: starOwner.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +266,6 @@ async function main() {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
gemini: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||
@@ -985,11 +975,11 @@ async function main() {
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
||||
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
||||
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
||||
async function maybeSuggestTitle(chatId: string, content: string) {
|
||||
const chatSummary = chats.find((chat) => chat.id === chatId);
|
||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
|
||||
|
||||
const chatId = chat.id;
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
@@ -999,8 +989,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1057,8 +1045,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
parentChatId: chat.parentChatId,
|
||||
titleGenerationPending: chat.titleGenerationPending,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
@@ -1078,13 +1064,13 @@ async function main() {
|
||||
throw new Error("Unable to initialize chat");
|
||||
}
|
||||
|
||||
void maybeSuggestTitle(chatId, content);
|
||||
|
||||
let baseChat = selectedChat;
|
||||
if (!baseChat || baseChat.id !== chatId) {
|
||||
baseChat = await api.getChat(chatId);
|
||||
}
|
||||
|
||||
void maybeSuggestTitle(baseChat, content);
|
||||
|
||||
const requestMessages: CompletionRequestMessage[] = [
|
||||
...baseChat.messages
|
||||
.filter((message) => !isToolCallLogMessage(message))
|
||||
@@ -1402,8 +1388,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
@@ -1417,16 +1401,12 @@ async function main() {
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
|
||||
const selectedParentChatId = selectedChat?.id === selectedItem.id
|
||||
? selectedChat.parentChatId
|
||||
: selectedSummary?.parentChatId;
|
||||
const rootChatId = selectedParentChatId ?? selectedItem.id;
|
||||
const rootSummary = chats.find((chat) => chat.id === rootChatId);
|
||||
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
@@ -1437,8 +1417,6 @@ async function main() {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
parentChatId: updated.parentChatId,
|
||||
titleGenerationPending: updated.titleGenerationPending,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
@@ -1449,8 +1427,7 @@ async function main() {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
||||
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user