Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5a973ac7 | ||
|
|
e2443162b0 | ||
|
|
048456b8a5 | ||
|
|
211fad972a | ||
|
|
a69c641481 | ||
|
|
9229896ad7 | ||
|
|
c5217b2710 | ||
|
|
abc1124d27 | ||
|
|
4721717022 | ||
|
|
87b7d9502f | ||
|
|
1952f4f358 | ||
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 | ||
|
|
a0e410155d | ||
|
|
b5a5d21767 | ||
|
|
0ad96f4f99 | ||
|
|
b0c2b74c16 | ||
|
|
14d2d8cb28 | ||
|
|
03836bbc4c | ||
|
|
76ce62025a | ||
|
|
b15473d24e | ||
|
|
a512a65844 | ||
|
|
207b44f67f | ||
|
|
c5ccd212c9 | ||
|
|
ee990dde5d | ||
|
|
036743fe41 | ||
|
|
4e7ea7ab68 |
@@ -1,286 +0,0 @@
|
|||||||
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/${SIGNING_KEYCHAIN}-${GITHUB_RUN_ID:-$(uuidgen)}.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[@]}"
|
|
||||||
else
|
|
||||||
security list-keychains -d user -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 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:-}"
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: TestFlight
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "release/ios/v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
testflight:
|
||||||
|
runs-on: macos-arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: "3.1.7"
|
||||||
|
bundler-cache: true
|
||||||
|
working-directory: ios
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||||
|
|
||||||
|
- name: Upload to TestFlight
|
||||||
|
working-directory: ios
|
||||||
|
env:
|
||||||
|
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
|
||||||
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||||
|
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
|
||||||
|
# act_runner does not propagate setup-ruby's PATH changes into later
|
||||||
|
# steps, so put the selected Ruby back on PATH or `bundle` resolves to
|
||||||
|
# the toolcache default and misses the gems installed above.
|
||||||
|
run: |
|
||||||
|
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
|
||||||
|
bundle exec fastlane ios beta
|
||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
ios/fastlane/README.md
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
|||||||
Vendored
+26
@@ -12,17 +12,43 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://server:8787/;
|
proxy_pass http://server:8787/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sw.js {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
expires -1;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /manifest.webmanifest {
|
location = /manifest.webmanifest {
|
||||||
default_type application/manifest+json;
|
default_type application/manifest+json;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
expires -1;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
expires -1;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
expires -1;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ services:
|
|||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||||
|
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
|
||||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||||
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
|
||||||
|
|||||||
+26
-9
@@ -34,11 +34,13 @@ Chat upload limits:
|
|||||||
"openai": { "models": ["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 },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||||
|
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||||
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||||
|
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ Chat upload limits:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
||||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
@@ -128,7 +130,7 @@ Behavior notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"additionalSystemPrompt": "optional stored system prompt",
|
"additionalSystemPrompt": "optional stored system prompt",
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
@@ -184,6 +186,7 @@ Behavior notes:
|
|||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title, 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.
|
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
- 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.
|
||||||
|
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||||
|
|
||||||
### `DELETE /v1/chats/:chatId`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
@@ -234,7 +237,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -285,6 +288,7 @@ Behavior notes:
|
|||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
|
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
@@ -293,13 +297,16 @@ Behavior notes:
|
|||||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||||
|
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
|
||||||
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||||
|
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
|
||||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
|
||||||
|
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||||
@@ -317,6 +324,16 @@ Behavior notes:
|
|||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||||
|
|
||||||
|
## Streaming Chat
|
||||||
|
|
||||||
|
### `POST /v1/chat-completions/stream`
|
||||||
|
|
||||||
|
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
|
||||||
|
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
|
||||||
|
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
|
||||||
|
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||||
|
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
|
|
||||||
### `GET /v1/searches`
|
### `GET /v1/searches`
|
||||||
@@ -416,9 +433,9 @@ Behavior notes:
|
|||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
@@ -468,9 +485,9 @@ Behavior notes:
|
|||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Authentication:
|
|||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"clientRequestId": "optional-client-generated-id",
|
||||||
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -61,15 +62,19 @@ Notes:
|
|||||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
|
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
|
||||||
|
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
|
||||||
|
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
|
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
## Attach Endpoint
|
## Attach Endpoint
|
||||||
@@ -173,18 +178,21 @@ Terminal tool-call event:
|
|||||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||||
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||||
|
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
|
||||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||||
|
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
|
||||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
|
||||||
|
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
|
||||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||||
|
|
||||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
|
||||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||||
- Stream may emit `tool_call` events while tool calls are executed.
|
- Stream may emit `tool_call` events while tool calls are executed.
|
||||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@ SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
|||||||
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
||||||
SYBIL_PROVISIONING_PROFILE_UUID=
|
SYBIL_PROVISIONING_PROFILE_UUID=
|
||||||
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||||
SYBIL_XCODE_CODE_SIGN_IDENTITY=Apple Distribution
|
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
|
||||||
|
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
|
||||||
SYBIL_SIGNING_CERTIFICATE_ID=
|
SYBIL_SIGNING_CERTIFICATE_ID=
|
||||||
SYBIL_SIGNING_KEYCHAIN=
|
SYBIL_SIGNING_KEYCHAIN=
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||||
|
|
||||||
|
## Release Workflow
|
||||||
|
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
|
||||||
|
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
|
||||||
|
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
|
||||||
|
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
@@ -51,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
- xAI: `grok-3-mini`
|
- xAI: `grok-3-mini`
|
||||||
|
- Gemini: `gemini-3.5-flash`
|
||||||
- Hermes Agent: `hermes-agent`
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: "1.10"
|
MARKETING_VERSION: "1.13.2"
|
||||||
CURRENT_PROJECT_VERSION: 11
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "fastlane", "~> 2.227"
|
gem "fastlane"
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ PLATFORMS
|
|||||||
ruby
|
ruby
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (~> 2.227)
|
fastlane
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.23
|
2.5.23
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
.frame(height: 18 + bottomContentInset)
|
.frame(height: 18 + bottomContentInset)
|
||||||
.id(bottomAnchorID)
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,6 @@ struct SybilChatTranscriptView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
scrollToBottom(with: proxy, animated: false)
|
|
||||||
}
|
}
|
||||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case openai
|
||||||
case anthropic
|
case anthropic
|
||||||
case xai
|
case xai
|
||||||
|
case gemini
|
||||||
case hermesAgent = "hermes-agent"
|
case hermesAgent = "hermes-agent"
|
||||||
|
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case .openai: return "OpenAI"
|
case .openai: return "OpenAI"
|
||||||
case .anthropic: return "Anthropic"
|
case .anthropic: return "Anthropic"
|
||||||
case .xai: return "xAI"
|
case .xai: return "xAI"
|
||||||
|
case .gemini: return "Gemini"
|
||||||
case .hermesAgent: return "Hermes Agent"
|
case .hermesAgent: return "Hermes Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ struct SybilSearchResultsView: View {
|
|||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 20 + topContentInset)
|
.padding(.top, 20 + topContentInset)
|
||||||
.padding(.bottom, 20 + bottomContentInset)
|
.padding(.bottom, 20 + bottomContentInset)
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
|
|||||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
|
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||||
|
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ final class SybilSettingsStore {
|
|||||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||||
|
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
|
||||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
self.preferredModelByProvider = preferredModels
|
self.preferredModelByProvider = preferredModels
|
||||||
@@ -54,6 +57,7 @@ final class SybilSettingsStore {
|
|||||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
.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",
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||||
|
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||||
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -72,12 +76,14 @@ final class SybilSettingsStore {
|
|||||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
|
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||||
|
|
||||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ extension Font {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SybilLayout {
|
||||||
|
static let webContentMaxWidth: CGFloat = 896
|
||||||
|
}
|
||||||
|
|
||||||
enum SybilTheme {
|
enum SybilTheme {
|
||||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ final class SybilViewModel {
|
|||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"],
|
.xai: ["grok-3-mini"],
|
||||||
|
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
.hermesAgent: ["hermes-agent"]
|
.hermesAgent: ["hermes-agent"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1751,13 +1752,16 @@ final class SybilViewModel {
|
|||||||
switch target {
|
switch target {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||||
|
let isSelectingDifferentChat = selectedChat?.id != chatID
|
||||||
let chat = try await client.getChat(chatID: chatID)
|
let chat = try await client.getChat(chatID: chatID)
|
||||||
guard selectedItem == target, draftKind == nil else {
|
guard selectedItem == target, draftKind == nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
if isSelectingDifferentChat {
|
||||||
requestChatBottomPin()
|
requestChatBottomPin()
|
||||||
|
}
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
|
|||||||
@@ -544,12 +544,14 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
|
let staleDetail = makeChatDetail(id: "chat-2", date: date, body: "stale transcript")
|
||||||
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
||||||
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
viewModel.isAuthenticated = true
|
viewModel.isAuthenticated = true
|
||||||
viewModel.isCheckingSession = false
|
viewModel.isCheckingSession = false
|
||||||
viewModel.selectedItem = .chat("chat-2")
|
viewModel.selectedItem = .chat("chat-2")
|
||||||
|
viewModel.selectedChat = staleDetail
|
||||||
|
|
||||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
@@ -559,7 +561,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
#expect(viewModel.chatBottomPinRequestID == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -675,6 +677,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||||
#expect(!viewModel.isLoadingSelection)
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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?
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# TestFlight Release CI
|
|
||||||
|
|
||||||
Gitea Actions publishes iOS releases from tags that match:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
release/vN.N.N
|
|
||||||
```
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag release/v1.10.0
|
|
||||||
git push origin release/v1.10.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The release job runs on the `xcode` runner label, imports the signing p12 into
|
|
||||||
a temporary per-user keychain, 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 temporary signing
|
|
||||||
keychain and installed profiles in an `always()` cleanup step.
|
|
||||||
|
|
||||||
Required repository secrets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
APP_STORE_CONNECT_API_KEY_ID
|
|
||||||
APP_STORE_CONNECT_API_ISSUER_ID
|
|
||||||
APP_STORE_CONNECT_API_KEY_CONTENT
|
|
||||||
APPSTORE_CERTIFICATES_FILE_BASE64
|
|
||||||
APPSTORE_CERTIFICATES_PASSWORD
|
|
||||||
APPSTORE_PROVISIONING_PROFILE_BASE64
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate or refresh the signing assets locally with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd ios
|
|
||||||
fastlane ios create_ci_signing
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
|
|
||||||
certificate and provisioning profile values into the repository secrets listed
|
|
||||||
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
|
|
||||||
default.
|
|
||||||
|
|
||||||
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 generic `Apple Distribution`
|
|
||||||
selector that Xcode uses during archive/export.
|
|
||||||
|
|
||||||
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.
|
|
||||||
+109
-423
@@ -1,478 +1,164 @@
|
|||||||
require "dotenv"
|
|
||||||
require "base64"
|
|
||||||
require "fileutils"
|
|
||||||
require "json"
|
|
||||||
require "net/http"
|
|
||||||
require "open3"
|
|
||||||
require "openssl"
|
|
||||||
require "securerandom"
|
|
||||||
require "shellwords"
|
require "shellwords"
|
||||||
require "uri"
|
|
||||||
require "yaml"
|
|
||||||
|
|
||||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
APP_IDENTIFIER = "net.buzzert.sybil2"
|
||||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
SCHEME = "Sybil"
|
||||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
TEAM_ID = "DQQH5H6GBD"
|
||||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
PROFILE_NAME = "Sybil AppStore CI"
|
||||||
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
|
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
|
||||||
SIGNING_CERTIFICATE_NAME = ENV["SYBIL_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution: James Magahern (DQQH5H6GBD)" : ENV["SYBIL_CODE_SIGN_IDENTITY"]
|
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
|
||||||
XCODE_CODE_SIGN_IDENTITY = ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution" : ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"]
|
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
|
||||||
IOS_ROOT = File.expand_path("..", __dir__)
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
|
|
||||||
SCHEME = "Sybil"
|
|
||||||
TARGET = "SybilApp"
|
|
||||||
|
|
||||||
def present?(value)
|
def present?(value)
|
||||||
!value.to_s.strip.empty?
|
!value.to_s.strip.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def capture(command)
|
def ci?
|
||||||
stdout, stderr, status = Open3.capture3(command)
|
present?(ENV["CI"])
|
||||||
return stdout.strip if status.success?
|
|
||||||
|
|
||||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def run_silent(*command, error_message:)
|
|
||||||
_stdout, stderr, status = Open3.capture3(*command)
|
|
||||||
return if status.success?
|
|
||||||
|
|
||||||
UI.user_error!("#{error_message}\n#{stderr.strip}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_keychains
|
|
||||||
capture("security list-keychains -d user").lines.map { |line| line.strip.delete('"') }.reject(&:empty?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def app_project_settings
|
|
||||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
|
||||||
end
|
|
||||||
|
|
||||||
def 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
|
|
||||||
end
|
|
||||||
project.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_marketing_version
|
|
||||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_build_number
|
|
||||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_version_tag(tag)
|
|
||||||
version = tag.to_s.strip.sub(%r{\Arelease/}, "").sub(/\Av/, "")
|
|
||||||
unless version.match?(/\A\d+\.\d+\.\d+\z/)
|
|
||||||
UI.user_error!("Release tag #{tag.inspect} must look like release/v1.10.0")
|
|
||||||
end
|
|
||||||
version
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def release_version
|
def release_version
|
||||||
tag = ENV["SYBIL_VERSION_TAG"]
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
|
||||||
normalize_version_tag(tag)
|
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
|
||||||
end
|
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
|
||||||
|
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
|
||||||
|
|
||||||
def xcode_build_setting(key, value)
|
unless match
|
||||||
"#{key.to_s.shellescape}=#{value.to_s.shellescape}"
|
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
|
||||||
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
|
end
|
||||||
|
|
||||||
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
match[1]
|
||||||
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
|
end
|
||||||
|
|
||||||
def bundle_id_resource_id
|
# App Store Connect requires CFBundleVersion to be unique and strictly
|
||||||
response = app_store_connect_request(
|
# increasing app-wide (not just per marketing version), so we derive it from
|
||||||
:get,
|
# the monotonic CI run number rather than querying TestFlight (that query can
|
||||||
"/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1"
|
# lag behind builds still processing and hand back a colliding value).
|
||||||
)
|
def build_number
|
||||||
id = response.fetch("data", []).first&.fetch("id", nil)
|
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
|
||||||
UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id)
|
|
||||||
id
|
unless value.to_s.match?(/\A\d+\z/)
|
||||||
|
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
|
||||||
|
end
|
||||||
|
|
||||||
|
value.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def recreate_app_store_profile(certificate_id)
|
def stamp_marketing_version(version)
|
||||||
existing = app_store_connect_request(
|
contents = File.read(APP_PROJECT_SPEC)
|
||||||
:get,
|
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||||
"/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200"
|
|
||||||
)
|
if updated == contents
|
||||||
existing.fetch("data", []).each do |profile|
|
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
|
||||||
app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
payload = {
|
File.write(APP_PROJECT_SPEC, updated)
|
||||||
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
|
end
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
private_lane :load_app_store_connect_api_key do
|
private_lane :app_store_api_key do
|
||||||
options = app_store_connect_key_options
|
app_store_connect_api_key(
|
||||||
UI.user_error!("App Store Connect API key is required") unless options
|
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
|
||||||
|
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
|
||||||
app_store_connect_api_key(options)
|
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
|
||||||
end
|
is_key_content_base64: true
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
|
# CI signs headlessly, so match needs a fresh unlocked keychain to import
|
||||||
signing_identity_p12(p12_path, p12_password)
|
# into. codesign resolves identities through the user keychain *search list*
|
||||||
|
# (first match wins; the --keychain flag does not restrict the lookup), and
|
||||||
|
# other projects' keychains on this runner hold the same identity but are
|
||||||
|
# usually locked — so ours must come first. delete_keychain in the beta
|
||||||
|
# lane's ensure removes both the keychain and its search-list entry, which
|
||||||
|
# also keeps our (later locked) copy from shadowing those other projects.
|
||||||
|
private_lane :prepare_ci_keychain do
|
||||||
|
next unless ci?
|
||||||
|
|
||||||
profile_path = recreate_app_store_profile(cert_id)
|
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
|
create_keychain(
|
||||||
|
name: CI_KEYCHAIN_NAME,
|
||||||
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
|
password: CI_KEYCHAIN_PASSWORD,
|
||||||
File.write(
|
unlock: true,
|
||||||
secrets_path,
|
timeout: 3600,
|
||||||
[
|
add_to_search_list: false
|
||||||
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)
|
others = sh("security list-keychains -d user", log: false)
|
||||||
|
.scan(/"([^"]+)"/)
|
||||||
|
.flatten
|
||||||
|
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
|
||||||
|
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
|
||||||
|
|
||||||
|
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
|
||||||
|
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
|
||||||
end
|
end
|
||||||
|
|
||||||
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
|
private_lane :sync_signing do |options|
|
||||||
UI.important("Add the values from #{secrets_path} as repository secrets.")
|
match(
|
||||||
end
|
type: "appstore",
|
||||||
|
readonly: options.fetch(:readonly),
|
||||||
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,
|
app_identifier: APP_IDENTIFIER,
|
||||||
version: version,
|
team_id: TEAM_ID,
|
||||||
api_key: api_key,
|
profile_name: PROFILE_NAME,
|
||||||
initial_build_number: local_build_number
|
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||||
).to_i
|
git_branch: "master",
|
||||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
git_full_name: "Sybil Release Bot",
|
||||||
rescue StandardError => e
|
git_user_email: "james.magahern@me.com",
|
||||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
api_key: options.fetch(:api_key)
|
||||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
desc "Create or update match signing assets"
|
||||||
|
lane :setup_signing do
|
||||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
sync_signing(api_key: app_store_api_key, readonly: false)
|
||||||
apply_release_signing_settings
|
|
||||||
|
|
||||||
xcode_args = [
|
|
||||||
xcode_build_setting("MARKETING_VERSION", version),
|
|
||||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
|
||||||
]
|
|
||||||
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
|
end
|
||||||
xcode_args = xcode_args.join(" ")
|
|
||||||
|
|
||||||
ipa_path = build_app(
|
desc "Build and upload to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
prepare_ci_keychain
|
||||||
|
|
||||||
|
api_key = app_store_api_key
|
||||||
|
|
||||||
|
version = release_version
|
||||||
|
stamp_marketing_version(version)
|
||||||
|
sh("xcodegen", "--spec", PROJECT_SPEC)
|
||||||
|
|
||||||
|
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
|
||||||
|
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
|
||||||
|
|
||||||
|
sync_signing(api_key: api_key, readonly: true)
|
||||||
|
|
||||||
|
build_app(
|
||||||
project: PROJECT_FILE,
|
project: PROJECT_FILE,
|
||||||
scheme: SCHEME,
|
scheme: SCHEME,
|
||||||
clean: true,
|
|
||||||
sdk: "iphoneos",
|
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
xcargs: [
|
||||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
|
||||||
xcargs: xcode_args,
|
"CODE_SIGN_STYLE=Manual",
|
||||||
|
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
|
||||||
|
].join(" "),
|
||||||
export_options: {
|
export_options: {
|
||||||
method: "app-store",
|
|
||||||
destination: "export",
|
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
provisioningProfiles: {
|
|
||||||
APP_IDENTIFIER => PROFILE_SPECIFIER
|
|
||||||
},
|
|
||||||
signingCertificate: XCODE_CODE_SIGN_IDENTITY,
|
|
||||||
teamID: TEAM_ID,
|
teamID: TEAM_ID,
|
||||||
manageAppVersionAndBuildNumber: false,
|
provisioningProfiles: {
|
||||||
uploadSymbols: true,
|
APP_IDENTIFIER => PROFILE_NAME
|
||||||
stripSwiftSymbols: true
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
|
||||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
|
||||||
|
|
||||||
upload_to_testflight(
|
upload_to_testflight(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
app_identifier: APP_IDENTIFIER,
|
|
||||||
ipa: ipa_path,
|
|
||||||
skip_waiting_for_build_processing: true
|
skip_waiting_for_build_processing: true
|
||||||
)
|
)
|
||||||
|
ensure
|
||||||
|
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
fastlane documentation
|
|
||||||
----
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
Make sure you have the latest version of the Xcode command line tools installed:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
|
||||||
|
|
||||||
# Available Actions
|
|
||||||
|
|
||||||
## iOS
|
|
||||||
|
|
||||||
### ios version
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios version
|
|
||||||
```
|
|
||||||
|
|
||||||
Show the version Fastlane will stamp into the next TestFlight archive
|
|
||||||
|
|
||||||
### ios 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).
|
|
||||||
+6
-4
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
||||||
- Personal chat database (chats/messages + LLM call log)
|
- Personal chat database (chats/messages + LLM call log)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `OPENAI_API_KEY`
|
- `OPENAI_API_KEY`
|
||||||
- `ANTHROPIC_API_KEY`
|
- `ANTHROPIC_API_KEY`
|
||||||
- `XAI_API_KEY`
|
- `XAI_API_KEY`
|
||||||
|
- `GEMINI_API_KEY`
|
||||||
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||||
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||||
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||||
- `EXA_API_KEY`
|
- `EXA_API_KEY`
|
||||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
|
||||||
|
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
|
||||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||||
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
|
||||||
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)
|
||||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
||||||
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
|
||||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
||||||
@@ -58,7 +60,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
||||||
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
|
gemini
|
||||||
hermes_agent @map("hermes-agent")
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||||
},
|
},
|
||||||
z.enum(["exa", "searxng"]).default("exa")
|
z.enum(["exa", "searxng", "brave"]).default("exa")
|
||||||
);
|
);
|
||||||
|
|
||||||
const BooleanFlagSchema = z.preprocess((value) => {
|
const BooleanFlagSchema = z.preprocess((value) => {
|
||||||
@@ -66,10 +66,12 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
XAI_API_KEY: z.string().optional(),
|
XAI_API_KEY: z.string().optional(),
|
||||||
|
GEMINI_API_KEY: z.string().optional(),
|
||||||
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||||
EXA_API_KEY: z.string().optional(),
|
EXA_API_KEY: z.string().optional(),
|
||||||
|
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||||
|
|
||||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||||
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
|
||||||
@@ -99,6 +101,14 @@ const EnvSchema = z.object({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["BRAVE_SEARCH_API_KEY"],
|
||||||
|
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { convert as htmlToText } from "html-to-text";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { searchBrave } from "../search/brave.js";
|
||||||
import { exaClient } from "../search/exa.js";
|
import { exaClient } from "../search/exa.js";
|
||||||
import { searchSearxng } from "../search/searxng.js";
|
import { searchSearxng } from "../search/searxng.js";
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
@@ -507,11 +508,33 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
|
const response = await searchBrave(args.query, {
|
||||||
|
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
||||||
|
includeDomains: args.includeDomains,
|
||||||
|
excludeDomains: args.excludeDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
searchEngine: "brave",
|
||||||
|
query: args.query,
|
||||||
|
requestId: response.requestId,
|
||||||
|
results: response.results.map((result, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
...result,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
const args = WebSearchArgsSchema.parse(input);
|
const args = WebSearchArgsSchema.parse(input);
|
||||||
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
|
||||||
return runSearxngWebSearchTool(args);
|
return runSearxngWebSearchTool(args);
|
||||||
}
|
}
|
||||||
|
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
|
||||||
|
return runBraveWebSearchTool(args);
|
||||||
|
}
|
||||||
return runExaWebSearchTool(args);
|
return runExaWebSearchTool(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import {
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
getUnstreamedText,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolAwareUsage,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
buildTopLevelSystemPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
parseImageDataUrl,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
type GeminiClient = {
|
||||||
|
apiKey: string;
|
||||||
|
baseURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERNAL_CORRECTION =
|
||||||
|
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||||
|
|
||||||
|
function normalizeModelResourceName(model: string) {
|
||||||
|
const trimmed = model.trim().replace(/^\/+/, "");
|
||||||
|
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
|
||||||
|
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
|
||||||
|
url.searchParams.set("key", client.apiKey);
|
||||||
|
for (const [key, value] of Object.entries(extraParams)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
if (params.temperature !== undefined) config.temperature = params.temperature;
|
||||||
|
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
|
||||||
|
return Object.keys(config).length ? config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
||||||
|
const input = schema as Record<string, unknown>;
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (typeof input.type === "string") output.type = input.type;
|
||||||
|
if (typeof input.description === "string") output.description = input.description;
|
||||||
|
if (typeof input.format === "string") output.format = input.format;
|
||||||
|
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
|
||||||
|
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
|
||||||
|
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
|
||||||
|
|
||||||
|
const items = toGeminiJsonSchema(input.items);
|
||||||
|
if (items) output.items = items;
|
||||||
|
|
||||||
|
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(input.properties)) {
|
||||||
|
const propertySchema = toGeminiJsonSchema(value);
|
||||||
|
if (propertySchema) properties[key] = propertySchema;
|
||||||
|
}
|
||||||
|
if (Object.keys(properties).length) output.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(output).length ? output : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeminiTools(tools: any[]) {
|
||||||
|
const functionDeclarations = tools
|
||||||
|
.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return null;
|
||||||
|
const declaration: Record<string, unknown> = {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
};
|
||||||
|
const parameters = toGeminiJsonSchema(tool.function.parameters);
|
||||||
|
if (parameters) declaration.parameters = parameters;
|
||||||
|
return declaration;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
const source = parseImageDataUrl(attachment);
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType: source.mediaType,
|
||||||
|
data: source.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length ? parts : [{ text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationContent(message: ChatMessage) {
|
||||||
|
if (message.role === "system") {
|
||||||
|
throw new Error("System messages must be handled separately for Gemini.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role === "assistant" ? "model" : "user",
|
||||||
|
parts: toContentParts(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseContents(messages: ChatMessage[]) {
|
||||||
|
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
|
||||||
|
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
|
||||||
|
return text ? { parts: [{ text }] } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
const normalized = normalizeUsage(usage);
|
||||||
|
if (!normalized) return false;
|
||||||
|
acc.inputTokens += normalized.inputTokens;
|
||||||
|
acc.outputTokens += normalized.outputTokens;
|
||||||
|
acc.totalTokens += normalized.totalTokens;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUsage(usage: any) {
|
||||||
|
if (!usage) return null;
|
||||||
|
const inputTokens = usage.promptTokenCount ?? 0;
|
||||||
|
const outputTokens = usage.candidatesTokenCount ?? 0;
|
||||||
|
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
|
||||||
|
return { inputTokens, outputTokens, totalTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidate(response: any) {
|
||||||
|
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParts(response: any) {
|
||||||
|
const parts = getCandidate(response)?.content?.parts;
|
||||||
|
return Array.isArray(parts) ? parts : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any) {
|
||||||
|
return getParts(response)
|
||||||
|
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyToolArgs(args: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(args ?? {});
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return parts
|
||||||
|
.filter((part) => part?.functionCall)
|
||||||
|
.map((part, index) => ({
|
||||||
|
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: part.functionCall.name ?? "unknown_tool",
|
||||||
|
arguments: stringifyToolArgs(part.functionCall.args),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
|
||||||
|
return {
|
||||||
|
functionResponse: {
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
response: toolResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCorrection(conversation: any[], text: string) {
|
||||||
|
conversation.push({ role: "model", parts: [{ text }] });
|
||||||
|
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseGeminiResponse(response: Response) {
|
||||||
|
const bodyText = await response.text();
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = bodyText ? JSON.parse(bodyText) : null;
|
||||||
|
} catch {
|
||||||
|
body = { raw: bodyText };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||||
|
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parseGeminiResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureMessage(response: any, text: string, toolCallCount: number) {
|
||||||
|
const promptBlockReason = response?.promptFeedback?.blockReason;
|
||||||
|
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
|
||||||
|
|
||||||
|
const candidate = getCandidate(response);
|
||||||
|
const finishReason = candidate?.finishReason;
|
||||||
|
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
|
||||||
|
if (text || toolCallCount > 0) return null;
|
||||||
|
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
|
||||||
|
const tools = toGeminiTools(enabledTools);
|
||||||
|
return {
|
||||||
|
contents: conversation,
|
||||||
|
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
|
||||||
|
generationConfig: generationConfig(params),
|
||||||
|
tools,
|
||||||
|
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const response = await generateContent(params, buildRequest(params, conversation, enabledTools));
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
|
||||||
|
|
||||||
|
const parts = getParts(response);
|
||||||
|
const text = extractText(response);
|
||||||
|
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
|
||||||
|
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({ role: "model", parts });
|
||||||
|
|
||||||
|
const toolResultParts: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({ role: "user", parts: toolResultParts });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSseBoundary(buffer: string) {
|
||||||
|
const crlf = buffer.indexOf("\r\n\r\n");
|
||||||
|
const lf = buffer.indexOf("\n\n");
|
||||||
|
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
||||||
|
if (lf === -1) return { index: crlf, length: 4 };
|
||||||
|
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvent(rawEvent: string) {
|
||||||
|
const data = rawEvent
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice("data:".length).trimStart())
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (!data || data === "[DONE]") return null;
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
||||||
|
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await parseGeminiResponse(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Gemini stream response did not include a body.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let boundary = findSseBoundary(buffer);
|
||||||
|
while (boundary) {
|
||||||
|
const rawEvent = buffer.slice(0, boundary.index);
|
||||||
|
buffer = buffer.slice(boundary.index + boundary.length);
|
||||||
|
const event = parseSseEvent(rawEvent);
|
||||||
|
if (event) yield event;
|
||||||
|
boundary = findSseBoundary(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode();
|
||||||
|
const tail = buffer.trim();
|
||||||
|
if (tail) {
|
||||||
|
const event = parseSseEvent(tail);
|
||||||
|
if (event) yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
let danglingToolIntentRetries = 0;
|
||||||
|
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
let text = "";
|
||||||
|
let latestUsage: any = null;
|
||||||
|
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
|
||||||
|
rawResponses.push(response);
|
||||||
|
if (response?.usageMetadata) latestUsage = response.usageMetadata;
|
||||||
|
const failureMessage = getFailureMessage(response, extractText(response), 0);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
const delta = extractText(response);
|
||||||
|
if (delta) {
|
||||||
|
text += delta;
|
||||||
|
yield { type: "delta", text: delta };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const roundParts: any[] = [];
|
||||||
|
let roundText = "";
|
||||||
|
let latestRoundResponse: any = null;
|
||||||
|
let latestRoundUsage: any = null;
|
||||||
|
|
||||||
|
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
|
||||||
|
rawResponses.push(response);
|
||||||
|
latestRoundResponse = response;
|
||||||
|
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
|
||||||
|
roundParts.push(...getParts(response));
|
||||||
|
roundText += extractText(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
|
||||||
|
|
||||||
|
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
|
||||||
|
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
|
||||||
|
if (failureMessage) throw new Error(failureMessage);
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(roundText, "");
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({ role: "model", parts: roundParts });
|
||||||
|
|
||||||
|
const toolResultParts: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({ role: "user", parts: toolResultParts });
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: {
|
||||||
|
streamed: true,
|
||||||
|
responses: rawResponses,
|
||||||
|
toolCallsUsed: totalToolCalls,
|
||||||
|
toolCallLimitReached: true,
|
||||||
|
api: "gemini.streamGenerateContent",
|
||||||
|
},
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,45 @@ import type { ChatMessage } from "../types.js";
|
|||||||
const INTERNAL_CORRECTION =
|
const INTERNAL_CORRECTION =
|
||||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||||
|
|
||||||
|
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
|
||||||
|
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
|
||||||
|
|
||||||
|
function readMaxTokens(value: unknown) {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelInfoMaxTokens(modelInfo: any) {
|
||||||
|
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
|
||||||
|
if (params.maxTokens) return params.maxTokens;
|
||||||
|
|
||||||
|
const cached = modelMaxTokensCache.get(params.model);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const retrieve = params.client?.models?.retrieve;
|
||||||
|
if (typeof retrieve === "function") {
|
||||||
|
const modelInfo = await retrieve.call(params.client.models, params.model);
|
||||||
|
const maxTokens = getModelInfoMaxTokens(modelInfo);
|
||||||
|
if (maxTokens) {
|
||||||
|
modelMaxTokensCache.set(params.model, {
|
||||||
|
maxTokens,
|
||||||
|
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
|
||||||
|
});
|
||||||
|
return maxTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ANTHROPIC_MAX_TOKENS;
|
||||||
|
}
|
||||||
|
|
||||||
function toTools(tools: any[]) {
|
function toTools(tools: any[]) {
|
||||||
return tools
|
return tools
|
||||||
.map((tool) => {
|
.map((tool) => {
|
||||||
@@ -160,11 +199,12 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
|||||||
|
|
||||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
if (!enabledTools.length) {
|
if (!enabledTools.length) {
|
||||||
const response = await params.client.messages.create({
|
const response = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
} as any);
|
} as any);
|
||||||
@@ -192,7 +232,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
|||||||
const response = await params.client.messages.create({
|
const response = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
@@ -248,6 +288,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
|
|||||||
|
|
||||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
if (!enabledTools.length) {
|
if (!enabledTools.length) {
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -259,7 +300,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
|||||||
const stream = await params.client.messages.create({
|
const stream = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -315,7 +356,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
|
|||||||
const stream = await params.client.messages.create({
|
const stream = await params.client.messages.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
max_tokens: params.maxTokens ?? 1024,
|
max_tokens: maxTokens,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||||
|
import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js";
|
||||||
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
import type { ChatMessage, Provider } from "./types.js";
|
import type { ChatMessage, Provider } from "./types.js";
|
||||||
|
|
||||||
type ProviderAdapterParams = {
|
type ProviderAdapterParams = {
|
||||||
@@ -27,7 +28,7 @@ export type ProviderChatAdapter = {
|
|||||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||||
|
|
||||||
type ChatProtocol = {
|
type ChatProtocol = {
|
||||||
id: ChatProtocolId;
|
id: ChatProtocolId;
|
||||||
@@ -39,6 +40,7 @@ type ModelCatalogSpec = {
|
|||||||
enabled?: () => boolean;
|
enabled?: () => boolean;
|
||||||
fetchModels(client: any): Promise<string[]>;
|
fetchModels(client: any): Promise<string[]>;
|
||||||
fallbackModels?: () => string[];
|
fallbackModels?: () => string[];
|
||||||
|
sortModels?: (models: string[]) => string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderBackendSpec = {
|
type ProviderBackendSpec = {
|
||||||
@@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = {
|
|||||||
stream: streamWithMessagesApi,
|
stream: streamWithMessagesApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiProtocol: ChatProtocol = {
|
||||||
|
id: "gemini",
|
||||||
|
complete: completeWithGeminiApi,
|
||||||
|
stream: streamWithGeminiApi,
|
||||||
|
};
|
||||||
|
|
||||||
const responsesProtocol: ChatProtocol = {
|
const responsesProtocol: ChatProtocol = {
|
||||||
id: "responses",
|
id: "responses",
|
||||||
complete: completeWithResponsesApi,
|
complete: completeWithResponsesApi,
|
||||||
@@ -77,6 +85,10 @@ function modelIdsFromListResponse(page: any) {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripModelResourcePrefix(model: string) {
|
||||||
|
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyResponsesApiModel(model: string) {
|
function isLikelyResponsesApiModel(model: string) {
|
||||||
const id = model.toLowerCase();
|
const id = model.toLowerCase();
|
||||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||||
@@ -86,6 +98,37 @@ function isLikelyResponsesApiModel(model: string) {
|
|||||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyGeminiChatModel(model: string) {
|
||||||
|
const id = model.toLowerCase();
|
||||||
|
if (!id.startsWith("gemini-")) return false;
|
||||||
|
if (id.includes("embedding") || id.includes("embed")) return false;
|
||||||
|
if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false;
|
||||||
|
if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false;
|
||||||
|
if (id.includes("computer-use") || id.includes("robotics")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferGeminiModels(models: string[]) {
|
||||||
|
const preferred = [
|
||||||
|
"gemini-3.5-flash",
|
||||||
|
"gemini-flash-latest",
|
||||||
|
"gemini-3.1-flash-lite",
|
||||||
|
"gemini-3-flash-preview",
|
||||||
|
"gemini-pro-latest",
|
||||||
|
];
|
||||||
|
const modelSet = new Set(models);
|
||||||
|
return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url: URL): Promise<any> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const body: any = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
@@ -160,6 +203,29 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
createClient: geminiClient,
|
||||||
|
plainProtocol: geminiProtocol,
|
||||||
|
toolProtocol: geminiProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
||||||
|
url.searchParams.set("key", client.apiKey);
|
||||||
|
url.searchParams.set("pageSize", "1000");
|
||||||
|
const page = await fetchJson(url);
|
||||||
|
return Array.isArray(page?.models)
|
||||||
|
? page.models
|
||||||
|
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
||||||
|
.map((model: any) => model?.name)
|
||||||
|
.filter((id: unknown): id is string => typeof id === "string")
|
||||||
|
.map(stripModelResourcePrefix)
|
||||||
|
.filter(isLikelyGeminiChatModel)
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
sortModels: preferGeminiModels,
|
||||||
|
},
|
||||||
|
},
|
||||||
"hermes-agent": {
|
"hermes-agent": {
|
||||||
createClient: hermesAgentClient,
|
createClient: hermesAgentClient,
|
||||||
plainProtocol: chatCompletionsProtocol,
|
plainProtocol: chatCompletionsProtocol,
|
||||||
@@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] {
|
|||||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||||
const spec = backendSpecs[provider].modelCatalog;
|
const spec = backendSpecs[provider].modelCatalog;
|
||||||
if (!spec) return [];
|
if (!spec) return [];
|
||||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||||
|
return spec.sortModels ? spec.sortModels(models) : models;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const apiToPrismaProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
"hermes-agent": "hermes_agent",
|
"hermes-agent": "hermes_agent",
|
||||||
} as const satisfies Record<Provider, PrismaProvider>;
|
} as const satisfies Record<Provider, PrismaProvider>;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const prismaToApiProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
hermes_agent: "hermes-agent",
|
hermes_agent: "hermes-agent",
|
||||||
"hermes-agent": "hermes-agent",
|
"hermes-agent": "hermes-agent",
|
||||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenAI from "openai";
|
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import OpenAI from "openai";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
|
||||||
export function openaiClient() {
|
export function openaiClient() {
|
||||||
@@ -13,6 +13,14 @@ export function xaiClient() {
|
|||||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function geminiClient() {
|
||||||
|
if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set");
|
||||||
|
return {
|
||||||
|
apiKey: env.GEMINI_API_KEY,
|
||||||
|
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isHermesAgentConfigured() {
|
export function isHermesAgentConfigured() {
|
||||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
if (shouldPersist && chatId && call) {
|
if (shouldPersist && chatId && call) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.message.create({
|
await tx.message.create({
|
||||||
data: { chatId, role: "assistant" as any, content: text },
|
data: {
|
||||||
|
chatId,
|
||||||
|
role: "assistant" as any,
|
||||||
|
content: text,
|
||||||
|
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await tx.llmCall.update({
|
await tx.llmCall.update({
|
||||||
where: { id: call.id },
|
where: { id: call.id },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const;
|
||||||
|
|
||||||
export type Provider = (typeof PROVIDERS)[number];
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export type ChatMessage = {
|
|||||||
export type MultiplexRequest = {
|
export type MultiplexRequest = {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
persist?: boolean;
|
persist?: boolean;
|
||||||
|
clientRequestId?: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
|||||||
+120
-13
@@ -16,7 +16,7 @@ import { exaClient } from "./search/exa.js";
|
|||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
|||||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
|
|
||||||
@@ -109,14 +109,21 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
|||||||
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
||||||
if (!toInsert.length) return;
|
if (!toInsert.length) return;
|
||||||
|
|
||||||
|
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
|
||||||
await prisma.message.createMany({
|
await prisma.message.createMany({
|
||||||
data: toInsert.map((m) => ({
|
data: toInsert.map((m, index) => {
|
||||||
|
const metadata = {
|
||||||
|
...(m.attachments?.length ? { attachments: m.attachments } : {}),
|
||||||
|
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
chatId,
|
chatId,
|
||||||
role: m.role as any,
|
role: m.role as any,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
|
metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +176,7 @@ const CompletionStreamBody = z
|
|||||||
.object({
|
.object({
|
||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
persist: z.boolean().optional(),
|
persist: z.boolean().optional(),
|
||||||
|
clientRequestId: z.string().trim().min(1).max(128).optional(),
|
||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
@@ -186,6 +194,13 @@ const CompletionStreamBody = z
|
|||||||
path: ["chatId"],
|
path: ["chatId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "clientRequestId requires a persisted stream with chatId",
|
||||||
|
path: ["clientRequestId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
@@ -399,6 +414,7 @@ function buildSseHeaders(originHeader: string | undefined) {
|
|||||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
|
const activeChatStreamRequestIds = new Map<string, string>();
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
const STARRED_PROJECT_ID = "starred";
|
const STARRED_PROJECT_ID = "starred";
|
||||||
|
|
||||||
@@ -554,6 +570,7 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
||||||
|
if (reply.raw.destroyed || reply.raw.writableEnded) return reply;
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders?.();
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
@@ -588,10 +605,24 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
|||||||
return { event: ev.type, data: ev };
|
return { event: ev.type, data: ev };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||||
const stream = new ActiveSseStream();
|
const stream = new ActiveSseStream();
|
||||||
activeChatStreams.set(chatId, stream);
|
activeChatStreams.set(chatId, stream);
|
||||||
|
if (clientRequestId) {
|
||||||
|
activeChatStreamRequestIds.set(chatId, clientRequestId);
|
||||||
|
} else {
|
||||||
|
activeChatStreamRequestIds.delete(chatId);
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||||
|
if (activeChatStreams.get(chatId) !== stream) return;
|
||||||
|
activeChatStreams.delete(chatId);
|
||||||
|
activeChatStreamRequestIds.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let sawTerminalEvent = false;
|
let sawTerminalEvent = false;
|
||||||
try {
|
try {
|
||||||
@@ -611,13 +642,54 @@ function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionSt
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
} finally {
|
} finally {
|
||||||
activeChatStreams.delete(chatId);
|
clearActiveChatStream(chatId, stream);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||||
|
const stream = registerActiveChatStream(chatId, body.clientRequestId);
|
||||||
|
executeActiveChatStream(chatId, body, stream);
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMetadataClientRequestId(metadata: unknown) {
|
||||||
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||||
|
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||||
|
return typeof clientRequestId === "string" ? clientRequestId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
|
||||||
|
const assistantMessages = await prisma.message.findMany({
|
||||||
|
where: { chatId, role: "assistant" as any },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { content: true, metadata: true },
|
||||||
|
});
|
||||||
|
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeChatSubmissionStream(
|
||||||
|
stream: ActiveSseStream,
|
||||||
|
chatId: string,
|
||||||
|
body: z.infer<typeof CompletionStreamBody>,
|
||||||
|
assistantText: string
|
||||||
|
) {
|
||||||
|
stream.emit("meta", {
|
||||||
|
type: "meta",
|
||||||
|
chatId,
|
||||||
|
callId: null,
|
||||||
|
provider: body.provider,
|
||||||
|
model: body.model,
|
||||||
|
});
|
||||||
|
stream.complete({
|
||||||
|
event: "done",
|
||||||
|
data: {
|
||||||
|
type: "done",
|
||||||
|
text: assistantText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const query = body.query?.trim();
|
const query = body.query?.trim();
|
||||||
@@ -935,7 +1007,18 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (existing.title?.trim()) 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";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
let suggestedRaw = "";
|
||||||
|
try {
|
||||||
|
suggestedRaw = await generateChatTitle(body.content);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn(
|
||||||
|
{
|
||||||
|
chatId: body.chatId,
|
||||||
|
err: getErrorMessage(err),
|
||||||
|
},
|
||||||
|
"chat title generation failed; using fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
await prisma.chat.updateMany({
|
||||||
@@ -1353,15 +1436,39 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
|
||||||
if (body.persist !== false && body.chatId) {
|
if (body.persist !== false && body.chatId) {
|
||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
const activeStream = activeChatStreams.get(body.chatId);
|
||||||
|
if (activeStream) {
|
||||||
|
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
||||||
|
return streamActiveRun(req, reply, activeStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.persist !== false && body.chatId) {
|
|
||||||
if (activeChatStreams.has(body.chatId)) {
|
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.clientRequestId) {
|
||||||
|
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy requests without an idempotency key retain the original behavior.
|
||||||
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||||
return streamActiveRun(req, reply, stream);
|
return streamActiveRun(req, reply, stream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
||||||
|
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
||||||
|
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
||||||
|
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
||||||
|
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
||||||
|
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
||||||
|
const MAX_RATE_LIMIT_RETRIES = 3;
|
||||||
|
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
||||||
|
|
||||||
|
type RateLimitPolicy = {
|
||||||
|
limit: number;
|
||||||
|
windowSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||||
|
let lastRequestAtMs = 0;
|
||||||
|
let nextRequestAtMs = 0;
|
||||||
|
let quotaUnavailableUntilMs = 0;
|
||||||
|
let requestQueue = Promise.resolve();
|
||||||
|
|
||||||
|
export type BraveSearchOptions = {
|
||||||
|
numResults: number;
|
||||||
|
includeDomains?: string[];
|
||||||
|
excludeDomains?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraveSearchResult = {
|
||||||
|
title: string | null;
|
||||||
|
url: string | null;
|
||||||
|
publishedDate: string | null;
|
||||||
|
author: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
text: string | null;
|
||||||
|
highlights: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BraveSearchResponse = {
|
||||||
|
query: string;
|
||||||
|
requestId: string | null;
|
||||||
|
results: BraveSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function clipText(input: string, maxCharacters: number) {
|
||||||
|
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactWhitespace(input: string) {
|
||||||
|
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBraveSearchApiKey() {
|
||||||
|
if (!env.BRAVE_SEARCH_API_KEY) {
|
||||||
|
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
||||||
|
}
|
||||||
|
return env.BRAVE_SEARCH_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(milliseconds: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIntervalSafety(intervalMs: number) {
|
||||||
|
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommaSeparatedNumbers(value: string | null) {
|
||||||
|
if (!value) return [];
|
||||||
|
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
||||||
|
if (!value) return [];
|
||||||
|
return value.split(",").flatMap((part) => {
|
||||||
|
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
||||||
|
if (!match) return [];
|
||||||
|
const limit = Number(match[1]);
|
||||||
|
const windowSeconds = Number(match[2]);
|
||||||
|
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
||||||
|
if (!policies.length) return null;
|
||||||
|
let burstIndex = 0;
|
||||||
|
for (let index = 1; index < policies.length; index += 1) {
|
||||||
|
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
||||||
|
}
|
||||||
|
return burstIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRateLimitState(headers: Headers) {
|
||||||
|
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
||||||
|
const burstIndex = getBurstPolicyIndex(policies);
|
||||||
|
if (burstIndex === null) return;
|
||||||
|
|
||||||
|
const burstPolicy = policies[burstIndex]!;
|
||||||
|
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
||||||
|
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
||||||
|
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
||||||
|
}
|
||||||
|
requestIntervalMs = learnedIntervalMs;
|
||||||
|
|
||||||
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||||
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||||
|
for (let index = 0; index < policies.length; index += 1) {
|
||||||
|
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
||||||
|
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
||||||
|
if (index === burstIndex) {
|
||||||
|
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
||||||
|
} else {
|
||||||
|
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLongTermQuotaAvailable() {
|
||||||
|
if (quotaUnavailableUntilMs <= Date.now()) {
|
||||||
|
quotaUnavailableUntilMs = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
||||||
|
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRateLimitSlot() {
|
||||||
|
const reservation = requestQueue.then(async () => {
|
||||||
|
while (true) {
|
||||||
|
assertLongTermQuotaAvailable();
|
||||||
|
const waitMs = nextRequestAtMs - Date.now();
|
||||||
|
if (waitMs <= 0) break;
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
lastRequestAtMs = Date.now();
|
||||||
|
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
||||||
|
});
|
||||||
|
requestQueue = reservation.catch(() => undefined);
|
||||||
|
await reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
||||||
|
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
||||||
|
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
||||||
|
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
||||||
|
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
||||||
|
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
||||||
|
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
||||||
|
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBrave(url: URL) {
|
||||||
|
const apiKey = requireBraveSearchApiKey();
|
||||||
|
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
||||||
|
await waitForRateLimitSlot();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
...buildBrowserLikeRequestHeaders("application/json"),
|
||||||
|
"X-Subscription-Token": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRateLimitState(response.headers);
|
||||||
|
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
||||||
|
|
||||||
|
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
||||||
|
await response.arrayBuffer();
|
||||||
|
if (retryDelayMs === null) {
|
||||||
|
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
||||||
|
}
|
||||||
|
await sleep(retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Brave Search API request failed after rate-limit retries.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(input: string) {
|
||||||
|
const trimmed = input.trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||||
|
return parsed.hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomains(input: string[] | undefined) {
|
||||||
|
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
||||||
|
if (!urlRaw) return false;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
||||||
|
const includeDomains = normalizeDomains(options.includeDomains);
|
||||||
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||||
|
return results.filter((result) => {
|
||||||
|
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||||
|
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
||||||
|
const includeDomains = normalizeDomains(options.includeDomains);
|
||||||
|
const excludeDomains = normalizeDomains(options.excludeDomains);
|
||||||
|
const includeClause =
|
||||||
|
includeDomains.length === 0
|
||||||
|
? ""
|
||||||
|
: includeDomains.length === 1
|
||||||
|
? `site:${includeDomains[0]}`
|
||||||
|
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
||||||
|
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
||||||
|
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
||||||
|
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
||||||
|
url.searchParams.set("q", buildBraveQuery(query, options));
|
||||||
|
url.searchParams.set("count", String(options.numResults));
|
||||||
|
url.searchParams.set("safesearch", "moderate");
|
||||||
|
url.searchParams.set("result_filter", "web");
|
||||||
|
url.searchParams.set("text_decorations", "false");
|
||||||
|
url.searchParams.set("extra_snippets", "true");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = compactWhitespace(value);
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWebResult(result: any): BraveSearchResult {
|
||||||
|
const description = stringOrNull(result?.description);
|
||||||
|
const extraSnippets = stringArray(result?.extra_snippets);
|
||||||
|
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
||||||
|
const combinedText = snippets.join("\n\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: stringOrNull(result?.title),
|
||||||
|
url: stringOrNull(result?.url),
|
||||||
|
publishedDate: stringOrNull(result?.page_age),
|
||||||
|
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
||||||
|
summary: description ? clipText(description, 1_400) : null,
|
||||||
|
text: combinedText ? clipText(combinedText, 700) : null,
|
||||||
|
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
||||||
|
const url = buildSearchUrl(query, options);
|
||||||
|
const response = await fetchBrave(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.arrayBuffer();
|
||||||
|
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
await response.arrayBuffer();
|
||||||
|
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
requestId: response.headers.get("x-request-id"),
|
||||||
|
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBraveRateLimitStateForTests() {
|
||||||
|
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
||||||
|
lastRequestAtMs = 0;
|
||||||
|
nextRequestAtMs = 0;
|
||||||
|
quotaUnavailableUntilMs = 0;
|
||||||
|
requestQueue = Promise.resolve();
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { env } from "../src/env.js";
|
||||||
|
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
|
||||||
|
|
||||||
|
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ input, init });
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
web: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: " Brave result ",
|
||||||
|
url: "https://docs.example.com/article",
|
||||||
|
description: "Main\n snippet",
|
||||||
|
extra_snippets: ["Extra snippet one", "Extra snippet two"],
|
||||||
|
page_age: "2026-07-18T12:00:00Z",
|
||||||
|
profile: { name: "Example Docs" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Excluded result",
|
||||||
|
url: "https://blocked.example.com/article",
|
||||||
|
description: "Should be filtered",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"x-request-id": "brave-request-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchBrave("latest docs", {
|
||||||
|
numResults: 5,
|
||||||
|
includeDomains: ["https://example.com/path"],
|
||||||
|
excludeDomains: ["blocked.example.com"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
const requestUrl = new URL(String(fetchCalls[0]?.input));
|
||||||
|
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
|
||||||
|
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
|
||||||
|
assert.equal(requestUrl.searchParams.get("count"), "5");
|
||||||
|
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
|
||||||
|
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
|
||||||
|
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
|
||||||
|
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
|
||||||
|
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
|
||||||
|
|
||||||
|
assert.deepEqual(response, {
|
||||||
|
query: "latest docs",
|
||||||
|
requestId: "brave-request-1",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Brave result",
|
||||||
|
url: "https://docs.example.com/article",
|
||||||
|
publishedDate: "2026-07-18T12:00:00Z",
|
||||||
|
author: "Example Docs",
|
||||||
|
summary: "Main snippet",
|
||||||
|
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
|
||||||
|
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave rejects requests without an API key", async () => {
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = undefined;
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
|
||||||
|
} finally {
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave reports non-JSON responses", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response("upstream error", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("test", { numResults: 1 }),
|
||||||
|
/Brave Search API returned text\/plain/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const requestStartedAt: number[] = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
requestStartedAt.push(Date.now());
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "1, 1999",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
searchBrave("burst one", { numResults: 1 }),
|
||||||
|
searchBrave("burst two", { numResults: 1 }),
|
||||||
|
searchBrave("burst three", { numResults: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(requestStartedAt.length, 3);
|
||||||
|
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
|
||||||
|
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
const requestStartedAt: number[] = [];
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
requestStartedAt.push(Date.now());
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "49, 0",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchBrave("learn upgraded policy", { numResults: 1 });
|
||||||
|
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
|
||||||
|
|
||||||
|
assert.equal(requestStartedAt.length, 9);
|
||||||
|
const burstStartedAt = requestStartedAt.slice(1);
|
||||||
|
for (let index = 1; index < burstStartedAt.length; index += 1) {
|
||||||
|
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
|
||||||
|
}
|
||||||
|
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave retries 429 responses after the burst window resets", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
let fetchCount = 0;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
const rateLimitHeaders = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
|
||||||
|
"x-ratelimit-reset": "1, 2678400",
|
||||||
|
};
|
||||||
|
if (fetchCount === 1) {
|
||||||
|
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
|
||||||
|
status: 429,
|
||||||
|
headers: rateLimitHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await searchBrave("retry burst", { numResults: 1 });
|
||||||
|
assert.equal(fetchCount, 2);
|
||||||
|
assert.ok(Date.now() - startedAt >= 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave does not wait for exhausted long-term quotas", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "0, 0",
|
||||||
|
"x-ratelimit-reset": "1, 100000",
|
||||||
|
},
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("quota exhausted", { numResults: 1 }),
|
||||||
|
/rate limit quota is exhausted beyond the retry window/
|
||||||
|
);
|
||||||
|
assert.ok(Date.now() - startedAt < 1_000);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
||||||
|
let fetchCount = 0;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Response(JSON.stringify({ web: { results: [] } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
||||||
|
"x-ratelimit-remaining": "0, 0",
|
||||||
|
"x-ratelimit-reset": "1, 100000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchBrave("last allowed query", { numResults: 1 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => searchBrave("over quota query", { numResults: 1 }),
|
||||||
|
/long-term quota is exhausted/
|
||||||
|
);
|
||||||
|
assert.equal(fetchCount, 1);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
||||||
|
resetBraveRateLimitStateForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -140,6 +140,94 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
let retrievedModel: string | null = null;
|
||||||
|
const client = {
|
||||||
|
models: {
|
||||||
|
retrieve: async (model: string) => {
|
||||||
|
retrievedModel = model;
|
||||||
|
return { id: model, max_tokens: 128000 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Done" }],
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await completeWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-max-default-test",
|
||||||
|
messages: [{ role: "user", content: "Say done" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(retrievedModel, "claude-max-default-test");
|
||||||
|
assert.equal(requestBody?.max_tokens, 128000);
|
||||||
|
assert.equal(result.text, "Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Messages API preserves explicit maxTokens", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
let didRetrieveModel = false;
|
||||||
|
const client = {
|
||||||
|
models: {
|
||||||
|
retrieve: async () => {
|
||||||
|
didRetrieveModel = true;
|
||||||
|
return { max_tokens: 128000 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 1, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Done" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-explicit-max-test",
|
||||||
|
messages: [{ role: "user", content: "Say done" }],
|
||||||
|
maxTokens: 4096,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(didRetrieveModel, false);
|
||||||
|
assert.equal(requestBody?.max_tokens, 4096);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
});
|
||||||
|
|
||||||
test("fetch_url sends browser-like navigation headers", async () => {
|
test("fetch_url sends browser-like navigation headers", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
|
|||||||
managedTools: true,
|
managedTools: true,
|
||||||
enabledTools: ["web_search"],
|
enabledTools: ["web_search"],
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), {
|
||||||
|
provider: "gemini",
|
||||||
|
protocol: "gemini",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||||
provider: "hermes-agent",
|
provider: "hermes-agent",
|
||||||
protocol: "chat-completions",
|
protocol: "chat-completions",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
|||||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
assert.equal(toPrismaProvider("gemini"), "gemini");
|
||||||
|
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
||||||
initiatedProvider: "hermes-agent",
|
initiatedProvider: "hermes-agent",
|
||||||
lastUsedProvider: "xai",
|
lastUsedProvider: "gemini",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
+5
-1
@@ -42,12 +42,13 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
"hermes-agent": ["hermes-agent"],
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
anthropic: { models: [], loadedAt: null, error: null },
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
|
gemini: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeTags(value: string) {
|
function escapeTags(value: string) {
|
||||||
@@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "gemini") return "Gemini";
|
||||||
if (provider === "hermes-agent") return "Hermes Agent";
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,7 @@ async function main() {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
gemini: null,
|
||||||
"hermes-agent": null,
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "node --test --experimental-strip-types tests/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+16
-2
@@ -3,10 +3,24 @@ self.addEventListener("install", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(self.clients.claim());
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
await self.clients.claim();
|
||||||
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
await Promise.all(
|
||||||
|
windows.map(async (client) => {
|
||||||
|
try {
|
||||||
|
await client.navigate(client.url);
|
||||||
|
} catch {
|
||||||
|
// The client may have closed while the new worker was activating.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.mode !== "navigate") return;
|
if (event.request.mode !== "navigate") return;
|
||||||
event.respondWith(fetch(event.request));
|
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||||
});
|
});
|
||||||
|
|||||||
+500
-68
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import type { TargetedTouchEvent } from "preact";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -62,9 +63,16 @@ import {
|
|||||||
type WorkspaceItem,
|
type WorkspaceItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
import {
|
||||||
|
getChatModelSelection,
|
||||||
|
getChatModelSelectionSyncKey,
|
||||||
|
} from "@/lib/chat-model-selection";
|
||||||
|
import {
|
||||||
|
resolveSidebarSelectionAfterRefresh,
|
||||||
|
type SidebarSelection,
|
||||||
|
} from "@/lib/sidebar-selection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
type SidebarItem = SidebarSelection & {
|
type SidebarItem = SidebarSelection & {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -92,6 +100,11 @@ type ActiveRunsState = {
|
|||||||
chats: Record<string, true>;
|
chats: Record<string, true>;
|
||||||
searches: Record<string, true>;
|
searches: Record<string, true>;
|
||||||
};
|
};
|
||||||
|
type RefreshCollectionsOptions = {
|
||||||
|
initialSelection?: SidebarSelection;
|
||||||
|
reportTransientError?: boolean;
|
||||||
|
selectFallback?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function readSidebarSelectionFromUrl(): SidebarSelection | null {
|
function readSidebarSelectionFromUrl(): SidebarSelection | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
@@ -123,6 +136,7 @@ const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
|||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
"hermes-agent": ["hermes-agent"],
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,13 +144,30 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
anthropic: { models: [], loadedAt: null, error: null },
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
|
gemini: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
||||||
const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
|
|
||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||||
|
const STREAM_RESUME_RETRY_DELAYS_MS = [0, 250, 750, 1500];
|
||||||
|
const MOBILE_LAYOUT_MEDIA_QUERY = "(max-width: 767px)";
|
||||||
|
const MOBILE_SWIPE_ACTIVATION_DISTANCE = 18;
|
||||||
|
const MOBILE_SWIPE_DIRECTION_DOMINANCE = 1.22;
|
||||||
|
const MOBILE_SWIPE_VELOCITY_PROJECTION_SECONDS = 0.18;
|
||||||
|
const MOBILE_SWIPE_COMPLETION_VELOCITY = 620;
|
||||||
|
|
||||||
|
type MobileWorkspaceSwipe = {
|
||||||
|
touchIdentifier: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
lastX: number;
|
||||||
|
lastTimestamp: number;
|
||||||
|
velocityX: number;
|
||||||
|
direction: -1 | 1 | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ProviderModelPreferences = Record<Provider, string | null>;
|
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||||
|
|
||||||
@@ -149,6 +180,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
gemini: null,
|
||||||
"hermes-agent": null,
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
||||||
@@ -156,6 +188,95 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
|||||||
searches: {},
|
searches: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isMobileLayout() {
|
||||||
|
return typeof window !== "undefined" && window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileSwipeLatchDistance() {
|
||||||
|
if (typeof window === "undefined") return 112;
|
||||||
|
return Math.min(Math.max(window.innerWidth * 0.28, 112), 152);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTouch(touches: TouchList, identifier: number) {
|
||||||
|
for (let index = 0; index < touches.length; index += 1) {
|
||||||
|
const touch = touches.item(index);
|
||||||
|
if (touch?.identifier === identifier) return touch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAppForeground() {
|
||||||
|
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
||||||
|
if (document.visibilityState !== "hidden") return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const finishIfReady = () => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
document.removeEventListener("visibilitychange", finishIfReady);
|
||||||
|
window.removeEventListener("pageshow", finishIfReady);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", finishIfReady);
|
||||||
|
window.addEventListener("pageshow", finishIfReady);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForRetry(delayMs: number) {
|
||||||
|
if (!delayMs) return Promise.resolve();
|
||||||
|
return new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryAfterAppResume<T>(operation: () => Promise<T>) {
|
||||||
|
await waitForAppForeground();
|
||||||
|
let lastError: unknown = new Error("Unable to reconnect");
|
||||||
|
|
||||||
|
for (const delayMs of STREAM_RESUME_RETRY_DELAYS_MS) {
|
||||||
|
await waitForRetry(delayMs);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRunsAfterResume() {
|
||||||
|
return retryAfterAppResume(getActiveRuns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClientRequestId() {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableStreamDisconnect(error: unknown) {
|
||||||
|
if (error instanceof TypeError) return true;
|
||||||
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("failed to fetch") ||
|
||||||
|
message.includes("load failed") ||
|
||||||
|
message.includes("network error") ||
|
||||||
|
message.includes("networkerror") ||
|
||||||
|
message.includes("no response stream") ||
|
||||||
|
message.includes("stream disconnected before completion")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNewPersistedUserMessage(chat: ChatDetail, previousMessageIds: Set<string>, content: string, attachments: ChatAttachment[]) {
|
||||||
|
return chat.messages.some((message) => {
|
||||||
|
if (previousMessageIds.has(message.id) || message.role !== "user" || message.content !== content) return false;
|
||||||
|
const persistedAttachmentIds = new Set(getMessageAttachments(message.metadata).map((attachment) => attachment.id));
|
||||||
|
return attachments.length === persistedAttachmentIds.size && attachments.every((attachment) => persistedAttachmentIds.has(attachment.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSCRIPT_BOTTOM_GAP = 20;
|
const TRANSCRIPT_BOTTOM_GAP = 20;
|
||||||
const REPLY_SCROLL_BUFFER_MIN = 288;
|
const REPLY_SCROLL_BUFFER_MIN = 288;
|
||||||
const REPLY_SCROLL_BUFFER_MAX = 576;
|
const REPLY_SCROLL_BUFFER_MAX = 576;
|
||||||
@@ -345,6 +466,7 @@ function loadStoredModelPreferences() {
|
|||||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||||
|
gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null,
|
||||||
"hermes-agent":
|
"hermes-agent":
|
||||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||||
};
|
};
|
||||||
@@ -354,7 +476,9 @@ function loadStoredModelPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredProvider(value: unknown): Provider {
|
function normalizeStoredProvider(value: unknown): Provider {
|
||||||
return value === "anthropic" || value === "xai" || value === "openai" || value === "hermes-agent" ? value : "openai";
|
return value === "anthropic" || value === "xai" || value === "gemini" || value === "openai" || value === "hermes-agent"
|
||||||
|
? value
|
||||||
|
: "openai";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
||||||
@@ -364,6 +488,7 @@ function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferenc
|
|||||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||||
|
gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null,
|
||||||
"hermes-agent":
|
"hermes-agent":
|
||||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||||
};
|
};
|
||||||
@@ -395,6 +520,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "gemini") return "Gemini";
|
||||||
if (provider === "hermes-agent") return "Hermes Agent";
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -423,14 +549,6 @@ function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
|
||||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
|
||||||
return {
|
|
||||||
provider: chat.lastUsedProvider,
|
|
||||||
model: chat.lastUsedModel.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
@@ -862,11 +980,14 @@ export default function App() {
|
|||||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||||
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
|
const backgroundSuspendedChatStreamsRef = useRef<Set<string>>(new Set());
|
||||||
const searchRunAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
const searchRunAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
||||||
const searchRunCountersRef = useRef<Map<string, number>>(new Map());
|
const searchRunCountersRef = useRef<Map<string, number>>(new Map());
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const wasSendingRef = useRef(false);
|
const wasSendingRef = useRef(false);
|
||||||
|
const suppressNextMobileComposerFocusRef = useRef(false);
|
||||||
|
const mobileWorkspaceSwipeRef = useRef<MobileWorkspaceSwipe | null>(null);
|
||||||
const pendingReplyScrollRef = useRef(false);
|
const pendingReplyScrollRef = useRef(false);
|
||||||
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
|
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
|
||||||
@@ -969,6 +1090,7 @@ export default function App() {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
chatStreamAbortRefs.current.clear();
|
chatStreamAbortRefs.current.clear();
|
||||||
|
backgroundSuspendedChatStreamsRef.current.clear();
|
||||||
for (const controller of searchRunAbortRefs.current.values()) {
|
for (const controller of searchRunAbortRefs.current.values()) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
@@ -1007,7 +1129,11 @@ export default function App() {
|
|||||||
resetWorkspaceState();
|
resetWorkspaceState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
const refreshCollections = async ({
|
||||||
|
initialSelection,
|
||||||
|
reportTransientError = true,
|
||||||
|
selectFallback = false,
|
||||||
|
}: RefreshCollectionsOptions = {}) => {
|
||||||
setIsLoadingCollections(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const nextWorkspaceItems = await listWorkspaceItems();
|
const nextWorkspaceItems = await listWorkspaceItems();
|
||||||
@@ -1016,26 +1142,17 @@ export default function App() {
|
|||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
setSearches(nextSearches);
|
setSearches(nextSearches);
|
||||||
|
|
||||||
setSelectedItem((current) => {
|
setSelectedItem((current) =>
|
||||||
const hasItem = (candidate: SidebarSelection | null) => {
|
resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
||||||
if (!candidate) return false;
|
initialSelection,
|
||||||
return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
selectFallback,
|
||||||
};
|
})
|
||||||
|
);
|
||||||
if (preferredSelection && hasItem(preferredSelection)) {
|
|
||||||
return preferredSelection;
|
|
||||||
}
|
|
||||||
if (hasItem(current)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const first = nextWorkspaceItems[0];
|
|
||||||
return first ? { kind: first.type, id: first.id } : null;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
} else {
|
} else if (reportTransientError || !isRecoverableStreamDisconnect(err)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1094,7 +1211,7 @@ export default function App() {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
} else {
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1112,7 +1229,7 @@ export default function App() {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
} else {
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1122,9 +1239,14 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const preferredSelection = initialRouteSelectionRef.current;
|
const initialSelection = initialRouteSelectionRef.current;
|
||||||
initialRouteSelectionRef.current = null;
|
initialRouteSelectionRef.current = null;
|
||||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
void Promise.all([
|
||||||
|
refreshCollections({ initialSelection: initialSelection ?? undefined, selectFallback: true }),
|
||||||
|
refreshModels(),
|
||||||
|
refreshChatTools(),
|
||||||
|
refreshActiveRuns(),
|
||||||
|
]);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1135,6 +1257,44 @@ export default function App() {
|
|||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const suspendChatStreams = () => {
|
||||||
|
for (const [chatId, controller] of chatStreamAbortRefs.current) {
|
||||||
|
backgroundSuspendedChatStreamsRef.current.add(chatId);
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
suspendChatStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError((current) => (current && isRecoverableStreamDisconnect(new Error(current)) ? null : current));
|
||||||
|
void refreshActiveRuns();
|
||||||
|
const currentSelection = selectedItemRef.current;
|
||||||
|
void refreshCollections({ reportTransientError: false });
|
||||||
|
if (currentSelection?.kind === "chat") {
|
||||||
|
void refreshChat(currentSelection.id);
|
||||||
|
} else if (currentSelection?.kind === "search") {
|
||||||
|
void refreshSearch(currentSelection.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePageShow = () => {
|
||||||
|
void refreshActiveRuns();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.addEventListener("pagehide", suspendChatStreams);
|
||||||
|
window.addEventListener("pageshow", handlePageShow);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("pagehide", suspendChatStreams);
|
||||||
|
window.removeEventListener("pageshow", handlePageShow);
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1229,10 +1389,18 @@ export default function App() {
|
|||||||
|
|
||||||
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||||
const transcriptViewKey = draftKind ? `draft:${draftKind}` : selectedKey ?? "empty";
|
const transcriptViewKey = draftKind ? `draft:${draftKind}` : selectedKey ?? "empty";
|
||||||
const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null;
|
const selectedChatPendingState =
|
||||||
const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
|
draftKind === null && selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null;
|
||||||
const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
|
const selectedSearchRunState =
|
||||||
const selectedSearchIsActive = selectedItem?.kind === "search" && (!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
|
draftKind === null && selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
|
||||||
|
const selectedChatIsActive =
|
||||||
|
draftKind === null &&
|
||||||
|
selectedItem?.kind === "chat" &&
|
||||||
|
(!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
|
||||||
|
const selectedSearchIsActive =
|
||||||
|
draftKind === null &&
|
||||||
|
selectedItem?.kind === "search" &&
|
||||||
|
(!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
|
||||||
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
||||||
const isSearchRunning = !!selectedSearchIsActive;
|
const isSearchRunning = !!selectedSearchIsActive;
|
||||||
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
|
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
|
||||||
@@ -1287,6 +1455,7 @@ export default function App() {
|
|||||||
wasSendingRef.current = isSendingActiveChat;
|
wasSendingRef.current = isSendingActiveChat;
|
||||||
if (isSendingActiveChat) return;
|
if (isSendingActiveChat) return;
|
||||||
if (wasSending) {
|
if (wasSending) {
|
||||||
|
suppressNextMobileComposerFocusRef.current = isMobileLayout();
|
||||||
shouldAutoScrollRef.current = false;
|
shouldAutoScrollRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1310,6 +1479,12 @@ export default function App() {
|
|||||||
if (isActiveSelectionSending) return;
|
if (isActiveSelectionSending) return;
|
||||||
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
|
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
|
||||||
if (!hasWorkspaceSelection) return;
|
if (!hasWorkspaceSelection) return;
|
||||||
|
const mobileLayout = isMobileLayout();
|
||||||
|
if (mobileLayout && (Boolean(selectedItem) || suppressNextMobileComposerFocusRef.current)) {
|
||||||
|
suppressNextMobileComposerFocusRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressNextMobileComposerFocusRef.current = false;
|
||||||
focusComposer();
|
focusComposer();
|
||||||
}, [draftKind, isActiveSelectionSending, selectedKey]);
|
}, [draftKind, isActiveSelectionSending, selectedKey]);
|
||||||
|
|
||||||
@@ -1332,7 +1507,10 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const messages = selectedChat?.messages ?? [];
|
const messages =
|
||||||
|
draftKind === null && selectedItem?.kind === "chat" && selectedChat?.id === selectedItem.id
|
||||||
|
? selectedChat.messages
|
||||||
|
: [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSearchMode && pendingAttachments.length) {
|
if (isSearchMode && pendingAttachments.length) {
|
||||||
@@ -1380,15 +1558,32 @@ export default function App() {
|
|||||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||||
}, [searches, selectedItem]);
|
}, [searches, selectedItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedChatModelSelection = useMemo(() => {
|
||||||
if (draftKind || selectedItem?.kind !== "chat") return;
|
if (draftKind || selectedItem?.kind !== "chat") return null;
|
||||||
const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null;
|
const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null;
|
||||||
const summarySelection = getChatModelSelection(selectedChatSummary);
|
const summarySelection = getChatModelSelection(selectedChatSummary);
|
||||||
const nextSelection = detailSelection ?? summarySelection;
|
return detailSelection ?? summarySelection;
|
||||||
if (!nextSelection) return;
|
}, [
|
||||||
setProvider(nextSelection.provider);
|
draftKind,
|
||||||
setModel(nextSelection.model);
|
selectedChat?.id,
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
selectedChat?.lastUsedModel,
|
||||||
|
selectedChat?.lastUsedProvider,
|
||||||
|
selectedChatSummary?.id,
|
||||||
|
selectedChatSummary?.lastUsedModel,
|
||||||
|
selectedChatSummary?.lastUsedProvider,
|
||||||
|
selectedItem?.id,
|
||||||
|
selectedItem?.kind,
|
||||||
|
]);
|
||||||
|
const selectedChatModelSelectionSyncKey = getChatModelSelectionSyncKey(
|
||||||
|
selectedItem?.kind === "chat" ? selectedItem.id : null,
|
||||||
|
selectedChatModelSelection
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedChatModelSelection) return;
|
||||||
|
setProvider(selectedChatModelSelection.provider);
|
||||||
|
setModel(selectedChatModelSelection.model);
|
||||||
|
}, [selectedChatModelSelectionSyncKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draftKind === "chat") return;
|
if (draftKind === "chat") return;
|
||||||
@@ -1439,6 +1634,7 @@ export default function App() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setDraftKind("chat");
|
setDraftKind("chat");
|
||||||
|
selectedItemRef.current = null;
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -1451,6 +1647,120 @@ export default function App() {
|
|||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetMobileWorkspaceSwipe = () => {
|
||||||
|
mobileWorkspaceSwipeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
|
if (
|
||||||
|
event.touches.length !== 1 ||
|
||||||
|
!isMobileLayout() ||
|
||||||
|
isMobileSidebarOpen ||
|
||||||
|
isQuickQuestionOpen ||
|
||||||
|
isChatSettingsOpen ||
|
||||||
|
renameChatDialog !== null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest("input, textarea, select, button, a, [role='dialog'], [data-mobile-swipe-ignore]")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event.touches.item(0);
|
||||||
|
if (!touch) return;
|
||||||
|
mobileWorkspaceSwipeRef.current = {
|
||||||
|
touchIdentifier: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
lastX: touch.clientX,
|
||||||
|
lastTimestamp: event.timeStamp,
|
||||||
|
velocityX: 0,
|
||||||
|
direction: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
|
const swipe = mobileWorkspaceSwipeRef.current;
|
||||||
|
if (!swipe) return;
|
||||||
|
const touch = findTouch(event.touches, swipe.touchIdentifier);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const deltaX = touch.clientX - swipe.startX;
|
||||||
|
const deltaY = touch.clientY - swipe.startY;
|
||||||
|
const horizontalTravel = Math.abs(deltaX);
|
||||||
|
const verticalTravel = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (swipe.direction === null) {
|
||||||
|
if (
|
||||||
|
verticalTravel >= MOBILE_SWIPE_ACTIVATION_DISTANCE &&
|
||||||
|
verticalTravel > horizontalTravel * MOBILE_SWIPE_DIRECTION_DOMINANCE
|
||||||
|
) {
|
||||||
|
resetMobileWorkspaceSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
horizontalTravel < MOBILE_SWIPE_ACTIVATION_DISTANCE ||
|
||||||
|
horizontalTravel < verticalTravel * MOBILE_SWIPE_DIRECTION_DOMINANCE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swipe.direction = deltaX > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = event.timeStamp - swipe.lastTimestamp;
|
||||||
|
if (elapsedMs > 0 && touch.clientX !== swipe.lastX) {
|
||||||
|
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
|
||||||
|
}
|
||||||
|
swipe.lastX = touch.clientX;
|
||||||
|
swipe.lastTimestamp = event.timeStamp;
|
||||||
|
if (event.cancelable) event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileWorkspaceTouchEnd = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
|
const swipe = mobileWorkspaceSwipeRef.current;
|
||||||
|
if (!swipe) return;
|
||||||
|
const touch = findTouch(event.changedTouches, swipe.touchIdentifier);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const direction = swipe.direction;
|
||||||
|
const deltaX = touch.clientX - swipe.startX;
|
||||||
|
const elapsedMs = event.timeStamp - swipe.lastTimestamp;
|
||||||
|
if (elapsedMs > 0 && touch.clientX !== swipe.lastX) {
|
||||||
|
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
|
||||||
|
}
|
||||||
|
resetMobileWorkspaceSwipe();
|
||||||
|
if (direction === null) return;
|
||||||
|
|
||||||
|
const directionalDistance = deltaX * direction;
|
||||||
|
const directionalVelocity = swipe.velocityX * direction;
|
||||||
|
const projectedDistance =
|
||||||
|
directionalDistance + directionalVelocity * MOBILE_SWIPE_VELOCITY_PROJECTION_SECONDS;
|
||||||
|
const shouldComplete =
|
||||||
|
directionalVelocity <= -MOBILE_SWIPE_COMPLETION_VELOCITY
|
||||||
|
? false
|
||||||
|
: directionalVelocity >= MOBILE_SWIPE_COMPLETION_VELOCITY ||
|
||||||
|
directionalDistance >= getMobileSwipeLatchDistance() ||
|
||||||
|
projectedDistance >= getMobileSwipeLatchDistance();
|
||||||
|
if (!shouldComplete) return;
|
||||||
|
|
||||||
|
if (direction > 0) {
|
||||||
|
setIsMobileSidebarOpen(true);
|
||||||
|
} else {
|
||||||
|
handleCreateChat();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileWorkspaceTouchCancel = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
|
const swipe = mobileWorkspaceSwipeRef.current;
|
||||||
|
if (!swipe || !findTouch(event.changedTouches, swipe.touchIdentifier)) return;
|
||||||
|
resetMobileWorkspaceSwipe();
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenQuickQuestion = () => {
|
const handleOpenQuickQuestion = () => {
|
||||||
setQuickQuestionError(null);
|
setQuickQuestionError(null);
|
||||||
setIsQuickQuestionOpen(true);
|
setIsQuickQuestionOpen(true);
|
||||||
@@ -1461,6 +1771,7 @@ export default function App() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setDraftKind("search");
|
setDraftKind("search");
|
||||||
|
selectedItemRef.current = null;
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -1788,7 +2099,7 @@ export default function App() {
|
|||||||
} else {
|
} else {
|
||||||
await deleteSearch(target.id);
|
await deleteSearch(target.id);
|
||||||
}
|
}
|
||||||
await refreshCollections();
|
await refreshCollections({ selectFallback: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
@@ -2043,6 +2354,7 @@ export default function App() {
|
|||||||
if (!baseChat || baseChat.id !== chatId) {
|
if (!baseChat || baseChat.id !== chatId) {
|
||||||
baseChat = await getChat(chatId);
|
baseChat = await getChat(chatId);
|
||||||
}
|
}
|
||||||
|
const previousMessageIds = new Set(baseChat.messages.map((message) => message.id));
|
||||||
|
|
||||||
setPendingChatStates((current) => ({
|
setPendingChatStates((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -2095,12 +2407,20 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const target: SidebarSelection = { kind: "chat", id: chatId };
|
||||||
|
const clientRequestId = createClientRequestId();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let replayedAssistantText = "";
|
||||||
|
const abortController = new AbortController();
|
||||||
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runCompletionStream(
|
await runCompletionStream(
|
||||||
{
|
{
|
||||||
chatId,
|
chatId,
|
||||||
|
clientRequestId,
|
||||||
provider,
|
provider,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
messages: requestMessages,
|
messages: requestMessages,
|
||||||
@@ -2123,6 +2443,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
replayedAssistantText += payload.text;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
@@ -2131,7 +2452,7 @@ export default function App() {
|
|||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||||
if (!isTarget) return message;
|
if (!isTarget) return message;
|
||||||
updated = true;
|
updated = true;
|
||||||
return { ...message, content: message.content + payload.text };
|
return { ...message, content: replayedAssistantText };
|
||||||
});
|
});
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||||
});
|
});
|
||||||
@@ -2153,30 +2474,90 @@ export default function App() {
|
|||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
streamErrorMessage = payload.message;
|
streamErrorMessage = payload.message;
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
{ signal: abortController.signal }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (streamErrorMessage) {
|
if (streamErrorMessage) {
|
||||||
throw new Error(streamErrorMessage);
|
throw new Error(streamErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections();
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
const currentSelection = selectedItemRef.current;
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
await refreshCollections({ reportTransientError: false });
|
||||||
await refreshChat(chatId);
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
}
|
}
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
if (isCurrentSelection(target)) {
|
||||||
requestSettleTranscriptTailSpacer();
|
requestSettleTranscriptTailSpacer();
|
||||||
}
|
}
|
||||||
return { kind: "chat", id: chatId };
|
return target;
|
||||||
} catch (err) {
|
} catch (caughtError) {
|
||||||
|
let err: unknown = caughtError;
|
||||||
|
const wasBackgroundSuspended =
|
||||||
|
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
|
const shouldResumeStream =
|
||||||
|
wasBackgroundSuspended ||
|
||||||
|
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
|
||||||
|
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldResumeStream) {
|
||||||
|
try {
|
||||||
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
|
setError(null);
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrying the same idempotent request either starts an unaccepted
|
||||||
|
// submission or replays the existing backend-owned stream. Keep
|
||||||
|
// the current optimistic transcript visible until replay begins.
|
||||||
|
continue;
|
||||||
|
} catch (resumeError) {
|
||||||
|
if (isRecoverableStreamDisconnect(resumeError)) {
|
||||||
|
setError(null);
|
||||||
|
await waitForAppForeground();
|
||||||
|
await waitForRetry(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
err = resumeError;
|
||||||
|
}
|
||||||
|
} else if (streamErrorMessage) {
|
||||||
|
try {
|
||||||
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
|
if (hasNewPersistedUserMessage(persistedChat, previousMessageIds, content, attachments)) {
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
|
setError(streamErrorMessage);
|
||||||
|
}
|
||||||
|
removePendingChatState(chatId);
|
||||||
|
removeActiveRun("chat", chatId);
|
||||||
|
requestSettleTranscriptTailSpacer();
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
} catch (reconcileError) {
|
||||||
|
err = reconcileError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
pendingReplyScrollRef.current = false;
|
pendingReplyScrollRef.current = false;
|
||||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2317,23 +2698,28 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections(target);
|
await refreshCollections();
|
||||||
return target;
|
return target;
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachToActiveChatStream = async (chatId: string) => {
|
async function attachToActiveChatStream(chatId: string, resetFromServer = false) {
|
||||||
if (chatStreamAbortRefs.current.has(chatId)) return;
|
if (chatStreamAbortRefs.current.has(chatId)) return;
|
||||||
const target: SidebarSelection = { kind: "chat", id: chatId };
|
const target: SidebarSelection = { kind: "chat", id: chatId };
|
||||||
|
addActiveRun("chat", chatId);
|
||||||
|
let shouldResetFromServer = resetFromServer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
addActiveRun("chat", chatId);
|
|
||||||
|
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseChat = selectedChat?.id === chatId ? selectedChat : await getChat(chatId);
|
const baseChat = await getChat(chatId);
|
||||||
|
const resetPendingState = shouldResetFromServer;
|
||||||
|
shouldResetFromServer = false;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
if (current[chatId]) return current;
|
if (!resetPendingState && current[chatId]) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
@@ -2404,12 +2790,41 @@ export default function App() {
|
|||||||
throw new Error(streamErrorMessage);
|
throw new Error(streamErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
await refreshCollections();
|
await refreshCollections();
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
await refreshChat(chatId);
|
await refreshChat(chatId);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abortController.signal.aborted) return;
|
const wasBackgroundSuspended =
|
||||||
|
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
|
const shouldResumeStream =
|
||||||
|
wasBackgroundSuspended ||
|
||||||
|
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
|
||||||
|
|
||||||
|
if (shouldResumeStream) {
|
||||||
|
shouldResetFromServer = true;
|
||||||
|
try {
|
||||||
|
const resumedRuns = await getActiveRunsAfterResume();
|
||||||
|
setActiveRuns(buildActiveRunsState(resumedRuns));
|
||||||
|
if (resumedRuns.chats.includes(chatId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
|
}
|
||||||
|
void refreshCollections();
|
||||||
|
return;
|
||||||
|
} catch (resumeError) {
|
||||||
|
err = resumeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted && !shouldResumeStream) return;
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("active chat stream not found")) {
|
if (message.includes("active chat stream not found")) {
|
||||||
await refreshActiveRuns();
|
await refreshActiveRuns();
|
||||||
@@ -2419,15 +2834,22 @@ export default function App() {
|
|||||||
} else if (isCurrentSelection(target)) {
|
} else if (isCurrentSelection(target)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
chatStreamAbortRefs.current.delete(chatId);
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
requestSettleTranscriptTailSpacer();
|
requestSettleTranscriptTailSpacer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const attachToActiveSearchStream = async (searchId: string) => {
|
const attachToActiveSearchStream = async (searchId: string) => {
|
||||||
if (searchRunAbortRefs.current.has(searchId)) return;
|
if (searchRunAbortRefs.current.has(searchId)) return;
|
||||||
@@ -2501,7 +2923,7 @@ export default function App() {
|
|||||||
{ signal: abortController.signal }
|
{ signal: abortController.signal }
|
||||||
);
|
);
|
||||||
|
|
||||||
await refreshCollections(target);
|
await refreshCollections();
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
await refreshSearch(searchId);
|
await refreshSearch(searchId);
|
||||||
}
|
}
|
||||||
@@ -2569,7 +2991,7 @@ export default function App() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
await refreshCollections({ kind: "chat", id: chat.id });
|
await refreshCollections();
|
||||||
await refreshChat(chat.id);
|
await refreshChat(chat.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -2731,7 +3153,7 @@ export default function App() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
await refreshCollections({ kind: "chat", id: chat.id });
|
await refreshCollections();
|
||||||
await refreshChat(chat.id);
|
await refreshChat(chat.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -2790,7 +3212,8 @@ export default function App() {
|
|||||||
await refreshSearch(sentTarget.id);
|
await refreshSearch(sentTarget.id);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!sentTarget || isCurrentSelection(sentTarget)) {
|
const shouldSuppressMobileChatFocus = sentTarget?.kind === "chat" && isMobileLayout();
|
||||||
|
if ((!sentTarget || isCurrentSelection(sentTarget)) && !shouldSuppressMobileChatFocus) {
|
||||||
focusComposer();
|
focusComposer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2977,7 +3400,13 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
<main
|
||||||
|
className="glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border"
|
||||||
|
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||||
|
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||||
|
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||||
|
onTouchCancel={handleMobileWorkspaceTouchCancel}
|
||||||
|
>
|
||||||
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -3052,7 +3481,10 @@ export default function App() {
|
|||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
|
<footer
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20"
|
||||||
|
data-mobile-swipe-ignore
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto mx-auto max-w-4xl rounded-2xl border bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20 transition",
|
"pointer-events-auto mx-auto max-w-4xl rounded-2xl border bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20 transition",
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
) : message.content.trim() ? (
|
) : message.content.trim() ? (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
markdown={message.content}
|
markdown={message.content}
|
||||||
|
openLinksInNewTab
|
||||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
mode?: MarkdownMode;
|
mode?: MarkdownMode;
|
||||||
resolveCitationIndex?: (href: string) => number | undefined;
|
resolveCitationIndex?: (href: string) => number | undefined;
|
||||||
|
openLinksInNewTab?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
||||||
@@ -28,17 +29,30 @@ markdownRenderer.table = (token) => {
|
|||||||
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderMarkdown(markdown: string) {
|
function setNewTabLinkAttributes(currentNode: Element) {
|
||||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
if (currentNode.tagName !== "A") return;
|
||||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
currentNode.setAttribute("target", "_blank");
|
||||||
|
currentNode.setAttribute("rel", "noopener noreferrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) {
|
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
|
||||||
|
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||||
|
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||||
|
|
||||||
|
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||||
|
try {
|
||||||
|
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||||
|
} finally {
|
||||||
|
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
const prepared =
|
const prepared =
|
||||||
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
||||||
return renderMarkdown(prepared);
|
return renderMarkdown(prepared, openLinksInNewTab);
|
||||||
}, [markdown, mode, resolveCitationIndex]);
|
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
|
||||||
|
|
||||||
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -286,6 +286,14 @@ textarea {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-content > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.md-table-scroll {
|
.md-table-scroll {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0.35rem 0 1rem;
|
margin: 0.35rem 0 1rem;
|
||||||
@@ -384,7 +392,8 @@ textarea {
|
|||||||
|
|
||||||
.md-content ul,
|
.md-content ul,
|
||||||
.md-content ol {
|
.md-content ol {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -396,6 +405,26 @@ textarea {
|
|||||||
padding-left: 1.35rem;
|
padding-left: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-content ul > li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content ul > li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.76em;
|
||||||
|
width: 0.36rem;
|
||||||
|
height: 0.36rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(188 86% 62%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px hsl(188 86% 62% / 0.12),
|
||||||
|
0 0 10px hsl(188 86% 62% / 0.42);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.md-content li + li {
|
.md-content li + li {
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-151
@@ -149,7 +149,7 @@ export type CompletionRequestMessage = {
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -450,6 +450,7 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let eventName = "message";
|
let eventName = "message";
|
||||||
let dataLines: string[] = [];
|
let dataLines: string[] = [];
|
||||||
|
let sawTerminalEvent = false;
|
||||||
|
|
||||||
const flushEvent = () => {
|
const flushEvent = () => {
|
||||||
if (!dataLines.length) {
|
if (!dataLines.length) {
|
||||||
@@ -466,6 +467,9 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(eventName, payload);
|
dispatch(eventName, payload);
|
||||||
|
if (eventName === "done" || eventName === "error") {
|
||||||
|
sawTerminalEvent = true;
|
||||||
|
}
|
||||||
|
|
||||||
dataLines = [];
|
dataLines = [];
|
||||||
eventName = "message";
|
eventName = "message";
|
||||||
@@ -505,6 +509,10 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushEvent();
|
flushEvent();
|
||||||
|
|
||||||
|
if (!sawTerminalEvent) {
|
||||||
|
throw new Error("Stream disconnected before completion");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSearchStream(
|
export async function runSearchStream(
|
||||||
@@ -528,87 +536,14 @@ export async function runSearchStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
await readSseStream(response, (eventName, payload) => {
|
||||||
const fallback = `${response.status} ${response.statusText}`;
|
|
||||||
let message = fallback;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
|
||||||
if (body.message) message = body.message;
|
|
||||||
} catch {
|
|
||||||
// keep fallback message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let eventName = "message";
|
|
||||||
let dataLines: string[] = [];
|
|
||||||
|
|
||||||
const flushEvent = () => {
|
|
||||||
if (!dataLines.length) {
|
|
||||||
eventName = "message";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataText = dataLines.join("\n");
|
|
||||||
let payload: any = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(dataText);
|
|
||||||
} catch {
|
|
||||||
payload = { message: dataText };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||||
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||||
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||||
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
dataLines = [];
|
|
||||||
eventName = "message";
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
|
||||||
const rawLine = buffer.slice(0, newlineIndex);
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
||||||
|
|
||||||
if (!line) {
|
|
||||||
flushEvent();
|
|
||||||
} else if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.length) {
|
|
||||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
||||||
if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
@@ -654,6 +589,7 @@ export async function runCompletionStream(
|
|||||||
body: {
|
body: {
|
||||||
chatId?: string | null;
|
chatId?: string | null;
|
||||||
persist?: boolean;
|
persist?: boolean;
|
||||||
|
clientRequestId?: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
@@ -679,86 +615,13 @@ export async function runCompletionStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
await readSseStream(response, (eventName, payload) => {
|
||||||
const fallback = `${response.status} ${response.statusText}`;
|
|
||||||
let message = fallback;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
|
||||||
if (body.message) message = body.message;
|
|
||||||
} catch {
|
|
||||||
// keep fallback message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let eventName = "message";
|
|
||||||
let dataLines: string[] = [];
|
|
||||||
|
|
||||||
const flushEvent = () => {
|
|
||||||
if (!dataLines.length) {
|
|
||||||
eventName = "message";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataText = dataLines.join("\n");
|
|
||||||
let payload: any = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(dataText);
|
|
||||||
} catch {
|
|
||||||
payload = { message: dataText };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
dataLines = [];
|
|
||||||
eventName = "message";
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
|
||||||
const rawLine = buffer.slice(0, newlineIndex);
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
||||||
|
|
||||||
if (!line) {
|
|
||||||
flushEvent();
|
|
||||||
} else if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.length) {
|
|
||||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
||||||
if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Provider } from "./api";
|
||||||
|
|
||||||
|
type PersistedChatModel = {
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatModelSelection = {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null {
|
||||||
|
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||||
|
return {
|
||||||
|
provider: chat.lastUsedProvider,
|
||||||
|
model: chat.lastUsedModel.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) {
|
||||||
|
if (!chatId || !selection) return null;
|
||||||
|
return JSON.stringify([chatId, selection.provider, selection.model]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||||
|
|
||||||
|
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
||||||
|
|
||||||
|
type ResolveSidebarSelectionOptions = {
|
||||||
|
initialSelection?: SidebarSelection;
|
||||||
|
selectFallback?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveSidebarSelectionAfterRefresh(
|
||||||
|
current: SidebarSelection | null,
|
||||||
|
workspaceItems: WorkspaceSelectionItem[],
|
||||||
|
{ initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {}
|
||||||
|
): SidebarSelection | null {
|
||||||
|
const hasItem = (candidate: SidebarSelection | null | undefined) => {
|
||||||
|
if (!candidate) return false;
|
||||||
|
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasItem(current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (hasItem(initialSelection)) {
|
||||||
|
return initialSelection ?? null;
|
||||||
|
}
|
||||||
|
if (!selectFallback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const first = workspaceItems[0];
|
||||||
|
return first ? { kind: first.type, id: first.id } : null;
|
||||||
|
}
|
||||||
+4
-1
@@ -2,7 +2,10 @@ export function registerServiceWorker() {
|
|||||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
void navigator.serviceWorker
|
||||||
|
.register("/sw.js", { updateViaCache: "none" })
|
||||||
|
.then((registration) => registration.update())
|
||||||
|
.catch((error: unknown) => {
|
||||||
console.warn("Sybil service worker registration failed", error);
|
console.warn("Sybil service worker registration failed", error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
getChatModelSelection,
|
||||||
|
getChatModelSelectionSyncKey,
|
||||||
|
} from "../src/lib/chat-model-selection.ts";
|
||||||
|
|
||||||
|
test("chat model selections are normalized from persisted metadata", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getChatModelSelection({
|
||||||
|
lastUsedProvider: "anthropic",
|
||||||
|
lastUsedModel: " claude-sonnet-4-5 ",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrelated chat updates do not change the model synchronization key", () => {
|
||||||
|
const beforeSettingsSave = getChatModelSelection({
|
||||||
|
lastUsedProvider: "openai",
|
||||||
|
lastUsedModel: "gpt-4.1-mini",
|
||||||
|
});
|
||||||
|
const afterSettingsSave = getChatModelSelection({
|
||||||
|
lastUsedProvider: "openai",
|
||||||
|
lastUsedModel: "gpt-4.1-mini",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getChatModelSelectionSyncKey("chat-1", beforeSettingsSave),
|
||||||
|
getChatModelSelectionSyncKey("chat-1", afterSettingsSave)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching chats or persisted models changes the synchronization key", () => {
|
||||||
|
const original = { provider: "openai", model: "gpt-4.1-mini" };
|
||||||
|
const updated = { provider: "gemini", model: "gemini-3.5-flash" };
|
||||||
|
|
||||||
|
assert.notEqual(
|
||||||
|
getChatModelSelectionSyncKey("chat-1", original),
|
||||||
|
getChatModelSelectionSyncKey("chat-2", original)
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
getChatModelSelectionSyncKey("chat-1", original),
|
||||||
|
getChatModelSelectionSyncKey("chat-1", updated)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
|
||||||
|
|
||||||
|
const workspaceItems = [
|
||||||
|
{ type: "chat", id: "completed-chat" },
|
||||||
|
{ type: "chat", id: "selected-chat" },
|
||||||
|
{ type: "search", id: "selected-search" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("a collection refresh preserves the current thread selection", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
|
||||||
|
{ kind: "chat", id: "selected-chat" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an initial route selection cannot override a current thread selection", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveSidebarSelectionAfterRefresh(
|
||||||
|
{ kind: "search", id: "selected-search" },
|
||||||
|
workspaceItems,
|
||||||
|
{ initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true }
|
||||||
|
),
|
||||||
|
{ kind: "search", id: "selected-search" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a collection refresh preserves an intentionally empty selection", () => {
|
||||||
|
assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initial load can select the URL thread or fall back to the first item", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveSidebarSelectionAfterRefresh(null, workspaceItems, {
|
||||||
|
initialSelection: { kind: "search", id: "selected-search" },
|
||||||
|
selectFallback: true,
|
||||||
|
}),
|
||||||
|
{ kind: "search", id: "selected-search" }
|
||||||
|
);
|
||||||
|
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), {
|
||||||
|
kind: "chat",
|
||||||
|
id: "completed-chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user