Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0062f37b9f | ||
|
|
0ae551615f | ||
|
|
88bef50ae7 | ||
|
|
0d069b4233 | ||
|
|
60bbe077e8 | ||
|
|
0b09d5425b | ||
|
|
c9a3015e35 | ||
|
|
abd8a80daa | ||
|
|
0f76ef91a9 | ||
|
|
72e2ffd898 | ||
|
|
4c610c89e1 | ||
|
|
477921563f | ||
|
|
0fca0e93ec | ||
|
|
f977f9943c | ||
|
|
f445730a41 | ||
|
|
76cb808c33 | ||
|
|
e167bd983f | ||
|
|
e4dd91564f | ||
|
|
3bfde476a6 | ||
|
|
b8676027db | ||
|
|
d36d2c60a3 | ||
|
|
3d7031bb40 | ||
|
|
fa9b725c77 | ||
|
|
a88987d08d | ||
|
|
e137ea1077 | ||
|
|
fad25d7f2b | ||
|
|
fb28508764 | ||
|
|
4365798f5e |
@@ -0,0 +1,286 @@
|
|||||||
|
name: TestFlight Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "release/v*.*.*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
testflight:
|
||||||
|
runs-on: xcode
|
||||||
|
env:
|
||||||
|
SIGNING_KEYCHAIN: sybil_signing_temp
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate release tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tag_name="${GITHUB_REF#refs/tags/}"
|
||||||
|
if [[ ! "$tag_name" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Release tag must match release/vN.N.N; got ${tag_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_version="${tag_name#release/v}"
|
||||||
|
{
|
||||||
|
echo "TAG_NAME=${tag_name}"
|
||||||
|
echo "RELEASE_VERSION=${release_version}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: "3.3"
|
||||||
|
|
||||||
|
- name: Install Ruby gems
|
||||||
|
working-directory: ios
|
||||||
|
run: bundle install
|
||||||
|
|
||||||
|
- name: Install release tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
missing_tools=()
|
||||||
|
for tool in xcodegen jq; do
|
||||||
|
if ! command -v "${tool}" >/dev/null 2>&1; then
|
||||||
|
missing_tools+=("${tool}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${#missing_tools[@]}" -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v brew >/dev/null 2>&1; then
|
||||||
|
echo "Missing required tools: ${missing_tools[*]}; Homebrew is not available to install them" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
brew install "${missing_tools[@]}"
|
||||||
|
|
||||||
|
- name: Install signing secrets
|
||||||
|
env:
|
||||||
|
APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
|
||||||
|
APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
|
||||||
|
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}"
|
||||||
|
: "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}"
|
||||||
|
: "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}"
|
||||||
|
|
||||||
|
keychain_password="$(uuidgen)"
|
||||||
|
previous_default_keychain="$(security default-keychain -d user | sed 's/[ "]//g' || true)"
|
||||||
|
if [[ "${previous_default_keychain}" == *"/${SIGNING_KEYCHAIN}"* || ! -e "${previous_default_keychain}" ]]; then
|
||||||
|
previous_default_keychain=""
|
||||||
|
fi
|
||||||
|
developer_dir="$(xcode-select -p)"
|
||||||
|
signing_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/sybil-signing.XXXXXX")"
|
||||||
|
mkdir -p "${HOME}/Library/Keychains"
|
||||||
|
keychain_name="${HOME}/Library/Keychains/login.keychain"
|
||||||
|
certificate_path="${signing_dir}/appstore-signing.p12"
|
||||||
|
wwdr_certificate_path="${signing_dir}/AppleWWDRCAG3.cer"
|
||||||
|
profile_path="${signing_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||||
|
profile_plist="${signing_dir}/profile.plist"
|
||||||
|
old_profile_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
|
||||||
|
xcode_profile_dir="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||||
|
mkdir -p "${old_profile_dir}" "${xcode_profile_dir}"
|
||||||
|
|
||||||
|
printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > "${certificate_path}"
|
||||||
|
printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${profile_path}"
|
||||||
|
curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -o "${wwdr_certificate_path}"
|
||||||
|
openssl smime -inform DER -verify -noverify -in "${profile_path}" -out "${profile_plist}" >/dev/null
|
||||||
|
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "${profile_plist}")"
|
||||||
|
profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "${profile_plist}")"
|
||||||
|
old_profile_path="${old_profile_dir}/${profile_uuid}.mobileprovision"
|
||||||
|
xcode_profile_path="${xcode_profile_dir}/${profile_uuid}.mobileprovision"
|
||||||
|
old_named_profile_path="${old_profile_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||||
|
xcode_named_profile_path="${xcode_profile_dir}/Sybil_AppStore_CI.mobileprovision"
|
||||||
|
cp "${profile_path}" "${old_profile_path}"
|
||||||
|
cp "${profile_path}" "${xcode_profile_path}"
|
||||||
|
cp "${profile_path}" "${old_named_profile_path}"
|
||||||
|
cp "${profile_path}" "${xcode_named_profile_path}"
|
||||||
|
|
||||||
|
base_keychains=()
|
||||||
|
while IFS= read -r existing_keychain; do
|
||||||
|
[[ -z "${existing_keychain}" ]] && continue
|
||||||
|
[[ "${existing_keychain}" == *"/${SIGNING_KEYCHAIN}"* ]] && continue
|
||||||
|
[[ ! -e "${existing_keychain}" ]] && continue
|
||||||
|
base_keychains+=("${existing_keychain}")
|
||||||
|
done < <(security list-keychains -d user | sed 's/[ "]//g')
|
||||||
|
|
||||||
|
security delete-keychain "${keychain_name}" >/dev/null 2>&1 || true
|
||||||
|
rm -f "${HOME}/Library/Keychains/${keychain_name}-db"
|
||||||
|
security create-keychain -p "${keychain_password}" "${keychain_name}"
|
||||||
|
security set-keychain-settings -lut 21600 "${keychain_name}"
|
||||||
|
security unlock-keychain -p "${keychain_password}" "${keychain_name}"
|
||||||
|
security import "${wwdr_certificate_path}" \
|
||||||
|
-k "${keychain_name}" \
|
||||||
|
-T /usr/bin/codesign \
|
||||||
|
-T /usr/bin/security \
|
||||||
|
-T /usr/bin/xcodebuild
|
||||||
|
security import "${certificate_path}" \
|
||||||
|
-k "${keychain_name}" \
|
||||||
|
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
|
||||||
|
-T /usr/bin/codesign \
|
||||||
|
-T /usr/bin/security \
|
||||||
|
-T /usr/bin/xcodebuild \
|
||||||
|
-T "${developer_dir}/usr/bin/xcodebuild"
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_name}"
|
||||||
|
if [[ "${#base_keychains[@]}" -gt 0 ]]; then
|
||||||
|
security list-keychains -d user -s "${keychain_name}" "${base_keychains[@]}"
|
||||||
|
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:-}"
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: TestFlight
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "release/ios/v*.*.*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
testflight:
|
|
||||||
name: Build and upload
|
|
||||||
runs-on: macos-arm64
|
|
||||||
timeout-minutes: 90
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out the release tag
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: "3.3.11"
|
|
||||||
bundler-cache: true
|
|
||||||
working-directory: ios
|
|
||||||
|
|
||||||
- name: Install XcodeGen
|
|
||||||
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
|
||||||
|
|
||||||
- name: Build and upload to TestFlight
|
|
||||||
working-directory: ios
|
|
||||||
env:
|
|
||||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
||||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
||||||
ASC_KEY: ${{ secrets.ASC_KEY }}
|
|
||||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
|
||||||
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
|
||||||
CI: "true"
|
|
||||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
|
||||||
FASTLANE_HIDE_CHANGELOG: "1"
|
|
||||||
run: bundle exec fastlane ios beta
|
|
||||||
+1
-2
@@ -1,3 +1,2 @@
|
|||||||
.env
|
.env
|
||||||
ios/fastlane/README.md
|
|
||||||
ios/fastlane/report.xml
|
|
||||||
|
|||||||
Vendored
-26
@@ -12,43 +12,17 @@ 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,12 +12,10 @@ 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}
|
||||||
|
|||||||
+12
-58
@@ -34,13 +34,11 @@ 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.
|
||||||
|
|
||||||
@@ -58,7 +56,7 @@ Chat upload limits:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
|
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
@@ -89,8 +87,6 @@ Behavior notes:
|
|||||||
"type": "chat",
|
"type": "chat",
|
||||||
"id": "chat-id",
|
"id": "chat-id",
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"parentChatId": null,
|
|
||||||
"titleGenerationPending": false,
|
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
"starred": true,
|
"starred": true,
|
||||||
@@ -119,8 +115,7 @@ Behavior notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
- The response currently combines up to 100 chats and up to 100 searches.
|
||||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
|
||||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
@@ -133,7 +128,7 @@ Behavior notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
"provider": "optional openai|anthropic|xai|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"],
|
||||||
@@ -151,32 +146,15 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `provider` and `model` must be supplied together when present.
|
- `provider` and `model` must be supplied together when present.
|
||||||
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
|
|
||||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
### `POST /v1/chats/:chatId/fork`
|
|
||||||
- Body: `{ "messageId"?: string }`
|
|
||||||
- Response: `{ "chat": ChatSummary }`
|
|
||||||
- Source chat not found: `404 { "message": "chat not found" }`
|
|
||||||
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
|
|
||||||
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
|
||||||
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
|
||||||
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
|
||||||
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
|
||||||
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
|
||||||
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
|
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||||
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
|
|
||||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||||
@@ -203,19 +181,13 @@ Behavior notes:
|
|||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||||
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
|
||||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||||
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
- 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 }`
|
||||||
- Not found: `404 { "message": "chat not found" }`
|
- Not found: `404 { "message": "chat not found" }`
|
||||||
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
|
|
||||||
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
|
|
||||||
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
|
|
||||||
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
|
|
||||||
|
|
||||||
### `GET /v1/chats/:chatId`
|
### `GET /v1/chats/:chatId`
|
||||||
- Response: `{ "chat": ChatDetail }`
|
- Response: `{ "chat": ChatDetail }`
|
||||||
@@ -262,7 +234,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -313,7 +285,6 @@ 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`.
|
||||||
@@ -322,16 +293,13 @@ 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`, `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.
|
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must 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, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to 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.
|
||||||
@@ -349,16 +317,6 @@ 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`
|
||||||
@@ -454,15 +412,13 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
"parentChatId": null,
|
|
||||||
"titleGenerationPending": false,
|
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
@@ -508,15 +464,13 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
"parentChatId": null,
|
|
||||||
"titleGenerationPending": false,
|
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
# Streaming Chat API Contract
|
# Streaming Chat API Contract
|
||||||
|
|
||||||
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
|
This document defines the server-sent events (SSE) contract for chat completions.
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
- `POST /v1/chats/:chatId/stream/attach`
|
- `POST /v1/chats/:chatId/stream/attach`
|
||||||
- `POST /v1/quick-questions/stream`
|
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||||
- Request body is JSON
|
- Request body is JSON
|
||||||
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||||
|
|
||||||
## Chat Completion Request Body
|
## Request Body
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"clientRequestId": "optional-client-generated-id",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -63,21 +61,15 @@ Notes:
|
|||||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
|
|
||||||
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
|
|
||||||
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
|
|
||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||||
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
|
|
||||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||||
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
|
||||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
## Attach Endpoint
|
## Attach Endpoint
|
||||||
@@ -91,31 +83,6 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
|||||||
|
|
||||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||||
|
|
||||||
## Quick Question Endpoint
|
|
||||||
|
|
||||||
`POST /v1/quick-questions/stream`
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
|
||||||
"model": "string",
|
|
||||||
"question": "What is the capital of France?",
|
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
|
||||||
"userLocation": "optional city, region, country",
|
|
||||||
"temperature": 0.2,
|
|
||||||
"maxTokens": 256
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- `question` is required, trimmed by the server, and must not be empty.
|
|
||||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
|
||||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
|
||||||
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
|
||||||
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
|
||||||
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
@@ -206,21 +173,18 @@ Terminal tool-call event:
|
|||||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
- `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), `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.
|
- `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.
|
||||||
- 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`/`gemini`):
|
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||||
- 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.
|
||||||
|
|||||||
+24
-5
@@ -1,5 +1,24 @@
|
|||||||
ASC_KEY_ID=
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
ASC_ISSUER_ID=
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
ASC_KEY=
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
MATCH_PASSWORD=
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
MATCH_GIT_BASIC_AUTHORIZATION=
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
||||||
|
SYBIL_PROVISIONING_PROFILE_UUID=
|
||||||
|
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||||
|
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
|
||||||
|
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
|
||||||
|
SYBIL_SIGNING_CERTIFICATE_ID=
|
||||||
|
SYBIL_SIGNING_KEYCHAIN=
|
||||||
|
|
||||||
|
# App Store Connect API key settings for TestFlight upload and signing setup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
- 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:
|
||||||
@@ -57,5 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
- xAI: `grok-3-mini`
|
- xAI: `grok-3-mini`
|
||||||
- Gemini: `gemini-3.5-flash`
|
|
||||||
- Hermes Agent: `hermes-agent`
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
|
||||||
<key>NSSupportsAutomaticTermination</key>
|
|
||||||
<false/>
|
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct SybilApp: App
|
|||||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
WindowGroup {
|
||||||
SplitView()
|
SplitView()
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
@@ -24,18 +24,9 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
SybilMacQuickQuestionController.shared.start()
|
|
||||||
#endif
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
SybilMacQuickQuestionController.shared.stop()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
@@ -65,22 +56,11 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
willConnectTo session: UISceneSession,
|
willConnectTo session: UISceneSession,
|
||||||
options connectionOptions: UIScene.ConnectionOptions
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
) {
|
) {
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
if scene is UIWindowScene, session.role == .windowApplication {
|
|
||||||
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if let shortcutItem = connectionOptions.shortcutItem {
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
|
||||||
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func windowScene(
|
func windowScene(
|
||||||
_ windowScene: UIWindowScene,
|
_ windowScene: UIWindowScene,
|
||||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: Sybil
|
- package: Sybil
|
||||||
product: Sybil
|
product: Sybil
|
||||||
- target: SybilMacQuickQuestion
|
|
||||||
destinationFilters: [macCatalyst]
|
|
||||||
embed: true
|
|
||||||
link: false
|
|
||||||
codeSign: true
|
|
||||||
copy:
|
|
||||||
destination: plugins
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
@@ -31,12 +24,11 @@ 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.13.2"
|
MARKETING_VERSION: "1.10"
|
||||||
CURRENT_PROJECT_VERSION: 11
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Carbon
|
|
||||||
|
|
||||||
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
|
||||||
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
|
||||||
@MainActor
|
|
||||||
@objc(SybilMacQuickQuestionPlugin)
|
|
||||||
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
|
||||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
|
||||||
private var panel: QuickQuestionPanel?
|
|
||||||
private var panelView: QuickQuestionPanelView?
|
|
||||||
private var hotKey: EventHotKeyRef?
|
|
||||||
private var eventHandler: EventHandlerRef?
|
|
||||||
private var outsideClickMonitor: Any?
|
|
||||||
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
|
||||||
private var isDismissing = false
|
|
||||||
private var shortcutAvailable = false
|
|
||||||
private var windowlessActivity: NSObjectProtocol?
|
|
||||||
private var latestState = SybilQuickQuestionPanelState()
|
|
||||||
private var previousApplication: NSRunningApplication?
|
|
||||||
|
|
||||||
required override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
var isVisible: Bool { panel?.isVisible == true }
|
|
||||||
|
|
||||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
|
||||||
self.delegate = delegate
|
|
||||||
if windowlessActivity == nil {
|
|
||||||
// Keep the hotkey service alive with no windows. This option does
|
|
||||||
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
|
||||||
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
|
||||||
options: .automaticTerminationDisabled,
|
|
||||||
reason: "Quick Question global shortcut"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
guard eventHandler == nil else { return noErr }
|
|
||||||
var eventTypes = [
|
|
||||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
|
||||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
|
||||||
]
|
|
||||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
|
||||||
let handlerStatus = InstallEventHandler(
|
|
||||||
GetApplicationEventTarget(),
|
|
||||||
{ _, event, context in
|
|
||||||
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
|
||||||
var identifier = EventHotKeyID()
|
|
||||||
let status = GetEventParameter(
|
|
||||||
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
|
||||||
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
|
||||||
)
|
|
||||||
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
|
||||||
return OSStatus(eventNotHandledErr)
|
|
||||||
}
|
|
||||||
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
|
||||||
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
|
||||||
// Application event handlers run on the main event loop.
|
|
||||||
MainActor.assumeIsolated {
|
|
||||||
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
|
||||||
plugin.delegate?.quickQuestionToggleRequested()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return noErr
|
|
||||||
},
|
|
||||||
eventTypes.count, &eventTypes, context, &eventHandler
|
|
||||||
)
|
|
||||||
guard handlerStatus == noErr else { return handlerStatus }
|
|
||||||
let status = RegisterEventHotKey(
|
|
||||||
UInt32(kVK_Space), UInt32(optionKey),
|
|
||||||
EventHotKeyID(signature: 0x53595151, id: 1),
|
|
||||||
GetApplicationEventTarget(), 0, &hotKey
|
|
||||||
)
|
|
||||||
shortcutAvailable = status == noErr
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateState(_ data: Data) {
|
|
||||||
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
|
||||||
// Updating a background request must not create a non-hosting window.
|
|
||||||
// The controller creates/orders the panel only after UIKit is foreground.
|
|
||||||
latestState = state
|
|
||||||
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
|
||||||
if isVisible { resizePanel() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func show() {
|
|
||||||
createPanelIfNeeded()
|
|
||||||
guard let panel, let panelView else { return }
|
|
||||||
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
|
||||||
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
|
||||||
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
|
||||||
let width = min(680, visibleFrame.width - 48)
|
|
||||||
panel.setContentSize(NSSize(width: width, height: 240))
|
|
||||||
panelView.layoutSubtreeIfNeeded()
|
|
||||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
|
||||||
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
|
||||||
let y = max(visibleFrame.minY + 24, top - height)
|
|
||||||
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
|
||||||
// The controller has verified UIKit foreground readiness. A
|
|
||||||
// nonactivating panel can then take focus without raising a workspace.
|
|
||||||
panel.makeKeyAndOrderFront(nil)
|
|
||||||
panel.makeFirstResponder(panelView.promptView)
|
|
||||||
panelView.preparePromptForPresentation()
|
|
||||||
installOutsideClickMonitor()
|
|
||||||
}
|
|
||||||
|
|
||||||
func hide() {
|
|
||||||
dismissPanel(restoringPreviousApplication: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func activateForQuickQuestion() {
|
|
||||||
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
|
||||||
frontmost != NSRunningApplication.current {
|
|
||||||
previousApplication = frontmost
|
|
||||||
}
|
|
||||||
// Do not order a window here. Catalyst must finish its own foreground
|
|
||||||
// transition before the controller calls show().
|
|
||||||
NSApp.activate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func activateMainWindow() {
|
|
||||||
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
|
||||||
previousApplication = nil
|
|
||||||
NSApp.activate()
|
|
||||||
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissPanel(restoringPreviousApplication: Bool) {
|
|
||||||
guard !isDismissing else { return }
|
|
||||||
let wasVisible = isVisible
|
|
||||||
isDismissing = true
|
|
||||||
if wasVisible { panel?.orderOut(nil) }
|
|
||||||
removeOutsideClickMonitor()
|
|
||||||
isDismissing = false
|
|
||||||
let applicationToRestore = previousApplication
|
|
||||||
previousApplication = nil
|
|
||||||
if restoringPreviousApplication, NSApp.isActive,
|
|
||||||
let applicationToRestore, !applicationToRestore.isTerminated {
|
|
||||||
NSApp.yieldActivation(to: applicationToRestore)
|
|
||||||
applicationToRestore.activate(options: [])
|
|
||||||
}
|
|
||||||
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
previousApplication = nil
|
|
||||||
hide()
|
|
||||||
removeOutsideClickMonitor()
|
|
||||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
|
||||||
if let eventHandler { RemoveEventHandler(eventHandler) }
|
|
||||||
hotKey = nil
|
|
||||||
eventHandler = nil
|
|
||||||
hotKeyState = SybilQuickQuestionHotKeyState()
|
|
||||||
if let windowlessActivity {
|
|
||||||
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
|
||||||
}
|
|
||||||
windowlessActivity = nil
|
|
||||||
delegate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
|
||||||
// Focus is already moving to a window/app the user chose.
|
|
||||||
dismissPanel(restoringPreviousApplication: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createPanelIfNeeded() {
|
|
||||||
guard panel == nil else { return }
|
|
||||||
let panel = QuickQuestionPanel(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
|
||||||
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
|
||||||
)
|
|
||||||
panel.title = "Quick Question"
|
|
||||||
panel.level = .floating
|
|
||||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
|
||||||
panel.isFloatingPanel = true
|
|
||||||
panel.hidesOnDeactivate = false
|
|
||||||
panel.becomesKeyOnlyIfNeeded = false
|
|
||||||
panel.worksWhenModal = true
|
|
||||||
panel.isReleasedWhenClosed = false
|
|
||||||
panel.isOpaque = false
|
|
||||||
panel.backgroundColor = .clear
|
|
||||||
panel.hasShadow = true
|
|
||||||
panel.isMovableByWindowBackground = true
|
|
||||||
panel.appearance = NSAppearance(named: .darkAqua)
|
|
||||||
panel.delegate = self
|
|
||||||
panel.onDismiss = { [weak self] in self?.hide() }
|
|
||||||
let view = QuickQuestionPanelView()
|
|
||||||
view.delegate = delegate
|
|
||||||
view.onDismiss = { [weak self] in self?.hide() }
|
|
||||||
panel.contentView = view
|
|
||||||
panelView = view
|
|
||||||
self.panel = panel
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resizePanel() {
|
|
||||||
guard let panel, let panelView else { return }
|
|
||||||
panelView.layoutSubtreeIfNeeded()
|
|
||||||
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
|
||||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
|
||||||
guard abs(height - panel.frame.height) > 1 else { return }
|
|
||||||
var frame = panel.frame
|
|
||||||
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
|
||||||
frame.size.height = height
|
|
||||||
panel.setFrame(frame, display: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func installOutsideClickMonitor() {
|
|
||||||
guard outsideClickMonitor == nil else { return }
|
|
||||||
// Mouse-only monitoring does not require Accessibility or Input
|
|
||||||
// Monitoring permission. No global keyboard event tap is installed.
|
|
||||||
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in self?.hide() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeOutsideClickMonitor() {
|
|
||||||
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
|
||||||
outsideClickMonitor = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class QuickQuestionPanel: NSPanel {
|
|
||||||
var onDismiss: (() -> Void)?
|
|
||||||
override var canBecomeKey: Bool { true }
|
|
||||||
override var canBecomeMain: Bool { false }
|
|
||||||
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
|
|
||||||
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
|
||||||
var onDismiss: (() -> Void)?
|
|
||||||
|
|
||||||
let promptView = QuickQuestionPromptView()
|
|
||||||
private let stack = NSStackView()
|
|
||||||
private let answerView = NSTextView()
|
|
||||||
private let answerScroll = NSScrollView()
|
|
||||||
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
|
||||||
private let providerPopup = NSPopUpButton()
|
|
||||||
private let modelPopup = NSPopUpButton()
|
|
||||||
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
|
|
||||||
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
|
|
||||||
private let sendButton = NSButton()
|
|
||||||
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
|
|
||||||
private var panelState = SybilQuickQuestionPanelState()
|
|
||||||
|
|
||||||
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
|
|
||||||
|
|
||||||
override init(frame frameRect: NSRect) {
|
|
||||||
super.init(frame: frameRect)
|
|
||||||
material = .hudWindow
|
|
||||||
blendingMode = .behindWindow
|
|
||||||
state = .active
|
|
||||||
wantsLayer = true
|
|
||||||
layer?.cornerRadius = 18
|
|
||||||
layer?.masksToBounds = true
|
|
||||||
layer?.borderWidth = 1
|
|
||||||
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
|
|
||||||
|
|
||||||
stack.orientation = .vertical
|
|
||||||
stack.alignment = .leading
|
|
||||||
stack.spacing = 12
|
|
||||||
stack.detachesHiddenViews = true
|
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(stack)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
|
||||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
|
||||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
|
||||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
|
|
||||||
])
|
|
||||||
|
|
||||||
addRow(makeHeader())
|
|
||||||
configureTextView(promptView, fontSize: 22)
|
|
||||||
promptView.delegate = self
|
|
||||||
promptView.isRichText = false
|
|
||||||
promptView.allowsUndo = true
|
|
||||||
promptView.setAccessibilityLabel("Quick question prompt")
|
|
||||||
promptView.onSubmit = { [weak self] in self?.submit() }
|
|
||||||
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
|
|
||||||
let promptScroll = makeScrollView(documentView: promptView)
|
|
||||||
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
|
|
||||||
addRow(promptScroll)
|
|
||||||
|
|
||||||
configureTextView(answerView, fontSize: 15)
|
|
||||||
answerView.isEditable = false
|
|
||||||
answerView.isSelectable = true
|
|
||||||
answerView.setAccessibilityLabel("Quick question answer")
|
|
||||||
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
|
|
||||||
answerScroll.drawsBackground = false
|
|
||||||
answerScroll.hasVerticalScroller = true
|
|
||||||
answerScroll.autohidesScrollers = true
|
|
||||||
answerScroll.documentView = answerView
|
|
||||||
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
|
|
||||||
addRow(answerScroll)
|
|
||||||
|
|
||||||
statusLabel.font = .systemFont(ofSize: 12)
|
|
||||||
statusLabel.textColor = .secondaryLabelColor
|
|
||||||
statusLabel.maximumNumberOfLines = 3
|
|
||||||
addRow(statusLabel)
|
|
||||||
addRow(makeControls())
|
|
||||||
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
|
|
||||||
hint.font = .systemFont(ofSize: 11)
|
|
||||||
hint.textColor = .secondaryLabelColor
|
|
||||||
addRow(hint)
|
|
||||||
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
|
|
||||||
let previous = panelState
|
|
||||||
panelState = next
|
|
||||||
if promptView.string != next.prompt, !promptView.hasMarkedText() {
|
|
||||||
promptView.string = next.prompt
|
|
||||||
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
|
|
||||||
}
|
|
||||||
promptView.isEditable = !next.isBusy
|
|
||||||
promptView.needsDisplay = true
|
|
||||||
if previous.answer != next.answer {
|
|
||||||
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
|
|
||||||
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
|
|
||||||
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
|
|
||||||
}
|
|
||||||
answerScroll.isHidden = next.answer.isEmpty
|
|
||||||
|
|
||||||
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
|
|
||||||
providerPopup.removeAllItems()
|
|
||||||
for provider in next.providers {
|
|
||||||
providerPopup.addItem(withTitle: provider.title)
|
|
||||||
providerPopup.lastItem?.representedObject = provider.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
|
|
||||||
providerPopup.selectItem(at: index)
|
|
||||||
}
|
|
||||||
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
|
|
||||||
modelPopup.removeAllItems()
|
|
||||||
var models = next.models
|
|
||||||
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
|
|
||||||
modelPopup.addItems(withTitles: models)
|
|
||||||
modelPopup.selectItem(withTitle: next.model)
|
|
||||||
}
|
|
||||||
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
|
||||||
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
|
||||||
convertButton.isEnabled = next.canConvert
|
|
||||||
sendButton.isEnabled = next.canSend
|
|
||||||
openAppButton.isHidden = next.isAuthenticated
|
|
||||||
convertButton.isHidden = !next.isAuthenticated
|
|
||||||
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
|
|
||||||
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
|
|
||||||
|
|
||||||
let status: String
|
|
||||||
if let error = next.error {
|
|
||||||
status = error
|
|
||||||
statusLabel.textColor = .systemRed
|
|
||||||
} else {
|
|
||||||
statusLabel.textColor = .secondaryLabelColor
|
|
||||||
if next.isCheckingSession {
|
|
||||||
status = "Connecting to Sybil…"
|
|
||||||
} else if !next.isAuthenticated {
|
|
||||||
status = "Connect in Sybil before asking a question."
|
|
||||||
} else if next.isConverting {
|
|
||||||
status = "Opening in chat…"
|
|
||||||
} else if next.isSending {
|
|
||||||
status = next.toolSummaries.last ?? "Thinking…"
|
|
||||||
} else {
|
|
||||||
status = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statusLabel.stringValue = status
|
|
||||||
statusLabel.isHidden = status.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
func preparePromptForPresentation() {
|
|
||||||
// Select only when invoked, never on streamed updates or normal edits.
|
|
||||||
promptView.selectAll(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
|
||||||
guard !panelState.isBusy else { return }
|
|
||||||
promptView.needsDisplay = true
|
|
||||||
delegate?.quickQuestionPromptChanged(promptView.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addRow(_ view: NSView) {
|
|
||||||
stack.addArrangedSubview(view)
|
|
||||||
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeHeader() -> NSView {
|
|
||||||
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
|
|
||||||
icon.contentTintColor = .systemPurple
|
|
||||||
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
|
||||||
let title = NSTextField(labelWithString: "Quick Question")
|
|
||||||
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
||||||
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
|
|
||||||
shortcutLabel.textColor = .secondaryLabelColor
|
|
||||||
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
|
|
||||||
close.isBordered = false
|
|
||||||
close.toolTip = "Close (Esc)"
|
|
||||||
close.setAccessibilityLabel("Close Quick Question")
|
|
||||||
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
||||||
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
|
|
||||||
row.spacing = 8
|
|
||||||
row.alignment = .centerY
|
|
||||||
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeControls() -> NSView {
|
|
||||||
providerPopup.target = self
|
|
||||||
providerPopup.action = #selector(changeProvider)
|
|
||||||
providerPopup.setAccessibilityLabel("Quick question provider")
|
|
||||||
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
|
||||||
modelPopup.target = self
|
|
||||||
modelPopup.action = #selector(changeModel)
|
|
||||||
modelPopup.setAccessibilityLabel("Quick question model")
|
|
||||||
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
|
|
||||||
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
convertButton.target = self
|
|
||||||
convertButton.action = #selector(convertToChat)
|
|
||||||
convertButton.bezelStyle = .rounded
|
|
||||||
openAppButton.target = self
|
|
||||||
openAppButton.action = #selector(openApp)
|
|
||||||
openAppButton.bezelStyle = .rounded
|
|
||||||
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
|
|
||||||
sendButton.imagePosition = .imageOnly
|
|
||||||
sendButton.bezelStyle = .circular
|
|
||||||
sendButton.contentTintColor = .systemPurple
|
|
||||||
sendButton.target = self
|
|
||||||
sendButton.action = #selector(submit)
|
|
||||||
sendButton.setAccessibilityLabel("Ask Quick Question")
|
|
||||||
sendButton.toolTip = "Ask (Return)"
|
|
||||||
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
||||||
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
|
|
||||||
row.spacing = 8
|
|
||||||
row.alignment = .centerY
|
|
||||||
row.detachesHiddenViews = true
|
|
||||||
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
|
|
||||||
view.drawsBackground = false
|
|
||||||
view.textColor = .labelColor
|
|
||||||
view.insertionPointColor = .labelColor
|
|
||||||
view.font = .systemFont(ofSize: fontSize)
|
|
||||||
view.textContainerInset = NSSize(width: 0, height: 4)
|
|
||||||
view.isHorizontallyResizable = false
|
|
||||||
view.isVerticallyResizable = true
|
|
||||||
view.autoresizingMask = [.width]
|
|
||||||
view.textContainer?.widthTracksTextView = true
|
|
||||||
view.textContainer?.lineFragmentPadding = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeScrollView(documentView: NSView) -> NSScrollView {
|
|
||||||
let scroll = NSScrollView()
|
|
||||||
scroll.drawsBackground = false
|
|
||||||
scroll.hasVerticalScroller = true
|
|
||||||
scroll.autohidesScrollers = true
|
|
||||||
scroll.documentView = documentView
|
|
||||||
return scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func renderAnswer(_ text: String) -> NSAttributedString {
|
|
||||||
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
|
||||||
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
|
|
||||||
let paragraph = NSMutableParagraphStyle()
|
|
||||||
paragraph.lineSpacing = 4
|
|
||||||
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
|
|
||||||
for run in parsed.runs {
|
|
||||||
let intent = run.inlinePresentationIntent ?? []
|
|
||||||
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
|
|
||||||
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
|
|
||||||
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
|
|
||||||
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
|
|
||||||
}
|
|
||||||
return rendered
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func closePanel() { onDismiss?() }
|
|
||||||
@objc private func changeProvider() {
|
|
||||||
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
|
|
||||||
delegate?.quickQuestionProviderChanged(provider)
|
|
||||||
}
|
|
||||||
@objc private func changeModel() {
|
|
||||||
guard let model = modelPopup.titleOfSelectedItem else { return }
|
|
||||||
delegate?.quickQuestionModelChanged(model)
|
|
||||||
}
|
|
||||||
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
|
|
||||||
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
|
|
||||||
@objc private func submit() {
|
|
||||||
guard panelState.canSend, !promptView.hasMarkedText() else { return }
|
|
||||||
delegate?.quickQuestionSubmitRequested()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class QuickQuestionPromptView: NSTextView {
|
|
||||||
var onSubmit: (() -> Void)?
|
|
||||||
var onToggle: (() -> Void)?
|
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
|
||||||
super.draw(dirtyRect)
|
|
||||||
guard string.isEmpty else { return }
|
|
||||||
("Ask anything…" as NSString).draw(
|
|
||||||
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
|
|
||||||
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
|
||||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
|
||||||
if event.keyCode == 49, modifiers == .option {
|
|
||||||
if !event.isARepeat { onToggle?() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
|
|
||||||
if !event.isARepeat { onSubmit?() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Let the text input system handle Shift+Return and IME confirmation.
|
|
||||||
super.keyDown(with: event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
targets:
|
|
||||||
SybilMacQuickQuestion:
|
|
||||||
type: bundle
|
|
||||||
platform: macOS
|
|
||||||
deploymentTarget: "15.0"
|
|
||||||
sources:
|
|
||||||
- Sources
|
|
||||||
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
|
|
||||||
dependencies:
|
|
||||||
- sdk: AppKit.framework
|
|
||||||
- sdk: Carbon.framework
|
|
||||||
settings:
|
|
||||||
base:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
|
|
||||||
PRODUCT_NAME: SybilMacQuickQuestion
|
|
||||||
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
|
|
||||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
SWIFT_VERSION: 6.0
|
|
||||||
SUPPORTS_MACCATALYST: NO
|
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
|
||||||
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
|
|
||||||
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
|
|
||||||
MACH_O_TYPE: mh_bundle
|
|
||||||
SKIP_INSTALL: YES
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "fastlane", "2.237.0"
|
gem "fastlane", "~> 2.227"
|
||||||
|
|||||||
+62
-75
@@ -1,49 +1,46 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.9)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.9.0)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1274.0)
|
aws-partitions (1.1109.0)
|
||||||
aws-sdk-core (3.254.0)
|
aws-sdk-core (3.224.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.130.0)
|
aws-sdk-kms (1.101.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.216.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.228.1)
|
aws-sdk-s3 (1.188.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.224.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.11.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.3.0)
|
base64 (0.2.0)
|
||||||
benchmark (0.5.0)
|
|
||||||
bigdecimal (4.1.2)
|
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
csv (3.3.6)
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (1.6.0)
|
excon (0.109.0)
|
||||||
logger
|
|
||||||
faraday (1.10.6)
|
faraday (1.10.6)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
@@ -73,45 +70,42 @@ GEM
|
|||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.1)
|
||||||
fastlane (2.237.0)
|
fastlane (2.230.0)
|
||||||
CFPropertyList (>= 2.3, < 5.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
abbrev (~> 0.1)
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.9.0, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.197)
|
aws-sdk-s3 (~> 1.0)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2.0)
|
||||||
benchmark (>= 0.1.0)
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
bundler (>= 2.4.0, < 5.0.0)
|
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
csv (~> 3.3)
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 2.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.1.0)
|
fastlane-sirp (>= 1.0.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.10.3, < 4)
|
jwt (>= 2.1.0, < 3)
|
||||||
logger (>= 1.6, < 2.0)
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multi_json (~> 1.12)
|
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
mutex_m (~> 0.3)
|
mutex_m (~> 0.3.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2)
|
nkf (~> 0.2.0)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
ostruct (>= 0.1.0)
|
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
@@ -126,46 +120,41 @@ GEM
|
|||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.106.0)
|
google-apis-androidpublisher_v3 (0.54.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (0.11.3)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (~> 1.9)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
httpclient (>= 2.8.3, < 3.a)
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
mutex_m
|
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
google-apis-iamcredentials_v1 (0.28.0)
|
rexml
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
google-apis-playcustomapp_v1 (0.18.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
google-apis-storage_v1 (0.65.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-storage_v1 (0.29.0)
|
||||||
google-cloud-core (1.9.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-cloud-core (1.6.1)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (2.2.2)
|
google-cloud-env (1.6.0)
|
||||||
base64 (~> 0.2)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
faraday (>= 1.0, < 3.a)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-errors (1.7.0)
|
google-cloud-storage (1.45.0)
|
||||||
google-cloud-storage (1.62.0)
|
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-core (>= 0.18, < 2)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
google-apis-iamcredentials_v1 (~> 0.18)
|
google-apis-storage_v1 (~> 0.29.0)
|
||||||
google-apis-storage_v1 (>= 0.42)
|
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (~> 1.9)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
google-logging-utils (0.2.0)
|
googleauth (1.8.1)
|
||||||
googleauth (1.17.2)
|
faraday (>= 0.17.3, < 3.a)
|
||||||
faraday (>= 1.0, < 3.a)
|
jwt (>= 1.4, < 3.0)
|
||||||
google-cloud-env (~> 2.2)
|
multi_json (~> 1.11)
|
||||||
google-logging-utils (~> 0.1)
|
|
||||||
jwt (>= 1.4, < 4.0)
|
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
pstore (~> 0.1)
|
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.8)
|
||||||
@@ -173,24 +162,22 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.21.1)
|
json (2.7.6)
|
||||||
jwt (3.2.0)
|
jwt (2.10.3)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.21.1)
|
multi_json (1.15.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.3.0)
|
nkf (0.2.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
pstore (0.2.1)
|
public_suffix (5.1.1)
|
||||||
public_suffix (7.0.5)
|
|
||||||
rake (13.4.2)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
@@ -202,10 +189,11 @@ GEM
|
|||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.22.0)
|
signet (0.18.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 4.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
@@ -218,16 +206,15 @@ GEM
|
|||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
|
unf (0.2.0)
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.28.1)
|
xcodeproj (1.27.0)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
atomos (~> 0.1.3)
|
atomos (~> 0.1.3)
|
||||||
base64
|
|
||||||
claide (>= 1.0.2, < 2.0)
|
claide (>= 1.0.2, < 2.0)
|
||||||
colored2 (~> 3.1)
|
colored2 (~> 3.1)
|
||||||
nanaimo (~> 0.4.0)
|
nanaimo (~> 0.4.0)
|
||||||
nkf
|
|
||||||
rexml (>= 3.3.6, < 4.0)
|
rexml (>= 3.3.6, < 4.0)
|
||||||
xcpretty (0.4.1)
|
xcpretty (0.4.1)
|
||||||
rouge (~> 3.28.0)
|
rouge (~> 3.28.0)
|
||||||
@@ -238,7 +225,7 @@ PLATFORMS
|
|||||||
ruby
|
ruby
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (= 2.237.0)
|
fastlane (~> 2.227)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.23
|
2.5.23
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
SybilQuickQuestionView(
|
SybilQuickQuestionView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -86,11 +85,7 @@ public struct SplitView: View {
|
|||||||
)
|
)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
.task {
|
.task {
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
||||||
#endif
|
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
presentPendingQuickQuestionIfPossible()
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
@@ -112,9 +107,6 @@ public struct SplitView: View {
|
|||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
||||||
#endif
|
|
||||||
viewModel.markAppActiveForNetwork()
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
@@ -159,13 +151,8 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPendingQuickQuestionPresentation = false
|
hasPendingQuickQuestionPresentation = false
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
||||||
SybilMacQuickQuestionController.shared.show()
|
|
||||||
#else
|
|
||||||
quickQuestionFocusRequest += 1
|
quickQuestionFocusRequest += 1
|
||||||
isQuickQuestionPresented = true
|
isQuickQuestionPresented = true
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleQuickQuestionDismissed() {
|
private func handleQuickQuestionDismissed() {
|
||||||
@@ -174,57 +161,23 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
public static let workspaceWindowID = "sybil-workspace"
|
|
||||||
|
|
||||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some Commands {
|
public var body: some Commands {
|
||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button("New Chat") {
|
Button("New Chat") {
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
windowCommands.newChatOrWindow(
|
|
||||||
newChat: keyboardActions?.newChat,
|
|
||||||
openWindow: openNewWindow
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
keyboardActions?.newChat()
|
keyboardActions?.newChat()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
|
||||||
#else
|
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
#endif
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
Button("New Window", action: openNewWindow)
|
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
|
||||||
#endif
|
|
||||||
|
|
||||||
Button("New Search") {
|
Button("New Search") {
|
||||||
keyboardActions?.newSearch()
|
keyboardActions?.newSearch()
|
||||||
}
|
}
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
|
||||||
#else
|
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
#endif
|
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
Divider()
|
|
||||||
Button("Quick Question") {
|
|
||||||
SybilMacQuickQuestionController.shared.toggle()
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.space, modifiers: .option)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Conversation") {
|
CommandMenu("Conversation") {
|
||||||
@@ -241,13 +194,6 @@ public struct SybilCommands: Commands {
|
|||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
private func openNewWindow() {
|
|
||||||
// Targeting a WindowGroup without a value creates a fresh scene every
|
|
||||||
// time, even when another workspace window is already open.
|
|
||||||
openWindow(id: Self.workspaceWindowID)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilKeyboardActions {
|
private struct SybilKeyboardActions {
|
||||||
|
|||||||
@@ -183,29 +183,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runQuickQuestionStream(
|
|
||||||
body: QuickQuestionStreamRequest,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
let request = try makeRequest(
|
|
||||||
path: "/v1/quick-questions/stream",
|
|
||||||
method: "POST",
|
|
||||||
body: AnyEncodable(body),
|
|
||||||
acceptsSSE: true
|
|
||||||
)
|
|
||||||
|
|
||||||
SybilLog.info(
|
|
||||||
SybilLog.network,
|
|
||||||
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
|
||||||
)
|
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
|
||||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -687,12 +664,6 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
var userLocation: String? = nil
|
var userLocation: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QuickQuestionStreamRequest: Codable, Sendable {
|
|
||||||
var provider: Provider
|
|
||||||
var model: String
|
|
||||||
var question: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var provider: Provider?
|
var provider: Provider?
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
func runQuickQuestionStream(
|
|
||||||
body: QuickQuestionStreamRequest,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws
|
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
.frame(height: 18 + bottomContentInset)
|
.frame(height: 18 + bottomContentInset)
|
||||||
.id(bottomAnchorID)
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
@@ -67,6 +66,7 @@ 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()
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// This Foundation-only contract is compiled into both the Catalyst client and the
|
|
||||||
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
|
||||||
@MainActor
|
|
||||||
@objc(SybilQuickQuestionPanelBridging)
|
|
||||||
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
|
||||||
init()
|
|
||||||
var isVisible: Bool { get }
|
|
||||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
|
||||||
func updateState(_ data: Data)
|
|
||||||
func show()
|
|
||||||
func hide()
|
|
||||||
func activateForQuickQuestion()
|
|
||||||
func activateMainWindow()
|
|
||||||
func stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@objc(SybilQuickQuestionPanelDelegate)
|
|
||||||
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
|
||||||
func quickQuestionToggleRequested()
|
|
||||||
func quickQuestionPromptChanged(_ prompt: String)
|
|
||||||
func quickQuestionSubmitRequested()
|
|
||||||
func quickQuestionProviderChanged(_ provider: String)
|
|
||||||
func quickQuestionModelChanged(_ model: String)
|
|
||||||
func quickQuestionConvertRequested()
|
|
||||||
func quickQuestionOpenAppRequested()
|
|
||||||
func quickQuestionPanelDismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
|
||||||
struct ProviderOption: Codable, Equatable, Sendable {
|
|
||||||
var id: String
|
|
||||||
var title: String
|
|
||||||
}
|
|
||||||
|
|
||||||
var prompt = ""
|
|
||||||
var answer = ""
|
|
||||||
var toolSummaries: [String] = []
|
|
||||||
var provider = ""
|
|
||||||
var providers: [ProviderOption] = []
|
|
||||||
var model = ""
|
|
||||||
var models: [String] = []
|
|
||||||
var isSending = false
|
|
||||||
var isConverting = false
|
|
||||||
var canSend = false
|
|
||||||
var canConvert = false
|
|
||||||
var isAuthenticated = false
|
|
||||||
var isCheckingSession = true
|
|
||||||
var error: String?
|
|
||||||
|
|
||||||
var isBusy: Bool { isSending || isConverting }
|
|
||||||
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
|
||||||
// per press/release pair, without suppressing a second deliberate press.
|
|
||||||
struct SybilQuickQuestionHotKeyState {
|
|
||||||
private var isPressed = false
|
|
||||||
|
|
||||||
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
|
||||||
if !isKeyDown {
|
|
||||||
isPressed = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard !isPressed else { return false }
|
|
||||||
isPressed = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
|
||||||
public static let shared = SybilMacQuickQuestionController()
|
|
||||||
|
|
||||||
private var pluginBundle: Bundle?
|
|
||||||
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
|
||||||
private var viewModel: SybilViewModel?
|
|
||||||
private var observationGeneration = 0
|
|
||||||
private var isPresentationRequested = false
|
|
||||||
private var activationRequested = false
|
|
||||||
private var presentationTask: Task<Void, Never>?
|
|
||||||
private var foregroundObservation: AnyCancellable?
|
|
||||||
private var notificationCenter = NotificationCenter.default
|
|
||||||
private var isApplicationInForeground: @MainActor () -> Bool = {
|
|
||||||
UIApplication.shared.applicationState != .background
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject foreground readiness as well as the panel so tests can exercise
|
|
||||||
// windowless activation without activating apps or showing real windows.
|
|
||||||
init(
|
|
||||||
bridge: any SybilQuickQuestionPanelBridging,
|
|
||||||
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
|
||||||
notificationCenter: NotificationCenter = NotificationCenter()
|
|
||||||
) {
|
|
||||||
self.bridge = bridge
|
|
||||||
self.isApplicationInForeground = isApplicationInForeground
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
super.init()
|
|
||||||
_ = bridge.start(delegate: self)
|
|
||||||
observeForegroundTransitionsIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func start() {
|
|
||||||
observeForegroundTransitionsIfNeeded()
|
|
||||||
guard bridge == nil else { return }
|
|
||||||
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
|
||||||
let bundle = Bundle(url: url)
|
|
||||||
else {
|
|
||||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try bundle.loadAndReturnError()
|
|
||||||
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
|
||||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let plugin = pluginClass.init()
|
|
||||||
pluginBundle = bundle
|
|
||||||
bridge = plugin
|
|
||||||
let status = plugin.start(delegate: self)
|
|
||||||
if status != 0 {
|
|
||||||
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func attach(_ viewModel: SybilViewModel) {
|
|
||||||
guard self.viewModel !== viewModel else { return }
|
|
||||||
// A window becoming active must not replace an in-flight quick question.
|
|
||||||
if self.viewModel != nil,
|
|
||||||
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.viewModel = viewModel
|
|
||||||
if bridge?.isVisible == true { updateAndObserve() }
|
|
||||||
}
|
|
||||||
|
|
||||||
public func toggle() {
|
|
||||||
start()
|
|
||||||
if isPresentationRequested || bridge?.isVisible == true {
|
|
||||||
cancelPresentation()
|
|
||||||
bridge?.hide()
|
|
||||||
} else {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func show() {
|
|
||||||
start()
|
|
||||||
isPresentationRequested = true
|
|
||||||
schedulePresentationIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func stop() {
|
|
||||||
cancelPresentation()
|
|
||||||
foregroundObservation = nil
|
|
||||||
viewModel?.cancelQuickQuestion()
|
|
||||||
bridge?.stop()
|
|
||||||
bridge = nil
|
|
||||||
// Keep the loaded bundle alive for the lifetime of its code.
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeForegroundTransitionsIfNeeded() {
|
|
||||||
guard foregroundObservation == nil else { return }
|
|
||||||
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
|
||||||
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.schedulePresentationIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func schedulePresentationIfNeeded() {
|
|
||||||
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
|
||||||
// Leave the Carbon/NSApplication event callback before touching windows.
|
|
||||||
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
|
||||||
// is not a substitute for checking UIKit's foreground lifecycle state.
|
|
||||||
presentationTask = Task { @MainActor [weak self] in
|
|
||||||
guard let self, !Task.isCancelled else { return }
|
|
||||||
self.presentationTask = nil
|
|
||||||
guard self.isPresentationRequested else { return }
|
|
||||||
guard self.isApplicationInForeground() else {
|
|
||||||
guard !self.activationRequested else { return }
|
|
||||||
self.activationRequested = true
|
|
||||||
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
|
||||||
self.bridge?.activateForQuickQuestion()
|
|
||||||
// Also handle an activation that completes synchronously. If it
|
|
||||||
// has not completed, subsequent UIKit notifications resume us.
|
|
||||||
self.schedulePresentationIfNeeded()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.activationRequested = false
|
|
||||||
self.updateAndObserve()
|
|
||||||
self.bridge?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cancelPresentation() {
|
|
||||||
isPresentationRequested = false
|
|
||||||
activationRequested = false
|
|
||||||
presentationTask?.cancel()
|
|
||||||
presentationTask = nil
|
|
||||||
observationGeneration += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAndObserve() {
|
|
||||||
guard let bridge else { return }
|
|
||||||
observationGeneration += 1
|
|
||||||
let generation = observationGeneration
|
|
||||||
let state = withObservationTracking {
|
|
||||||
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
|
||||||
} onChange: { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
|
||||||
self.updateAndObserve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
bridge.updateState(try JSONEncoder().encode(state))
|
|
||||||
} catch {
|
|
||||||
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionToggleRequested() {
|
|
||||||
toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionPromptChanged(_ prompt: String) {
|
|
||||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
|
||||||
viewModel.updateQuickQuestionPrompt(prompt)
|
|
||||||
updateAndObserve()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionSubmitRequested() {
|
|
||||||
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
|
||||||
viewModel.sendQuickQuestion()
|
|
||||||
updateAndObserve()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionProviderChanged(_ provider: String) {
|
|
||||||
guard let viewModel, let provider = Provider(rawValue: provider),
|
|
||||||
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
|
||||||
viewModel.setQuickQuestionProvider(provider)
|
|
||||||
updateAndObserve()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionModelChanged(_ model: String) {
|
|
||||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
|
||||||
viewModel.setQuickQuestionModel(model)
|
|
||||||
updateAndObserve()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionConvertRequested() {
|
|
||||||
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
if await viewModel.convertQuickQuestionToChat() {
|
|
||||||
self.cancelPresentation()
|
|
||||||
self.openMainWindow()
|
|
||||||
self.bridge?.hide()
|
|
||||||
} else {
|
|
||||||
self.updateAndObserve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionOpenAppRequested() {
|
|
||||||
cancelPresentation()
|
|
||||||
openMainWindow()
|
|
||||||
bridge?.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openMainWindow() {
|
|
||||||
let session = UIApplication.shared.connectedScenes
|
|
||||||
.compactMap { $0 as? UIWindowScene }
|
|
||||||
.first?.session
|
|
||||||
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
|
||||||
bridge?.activateMainWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickQuestionPanelDismissed() {
|
|
||||||
// Toggling out preserves the draft and lets an answer finish. Reopening
|
|
||||||
// reads the latest state, without starting a new request.
|
|
||||||
cancelPresentation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SybilViewModel {
|
|
||||||
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
|
||||||
SybilQuickQuestionPanelState(
|
|
||||||
prompt: quickQuestionPrompt,
|
|
||||||
answer: quickQuestionAnswerText,
|
|
||||||
toolSummaries: quickQuestionMessages.compactMap { message in
|
|
||||||
guard let metadata = message.toolCallMetadata else { return nil }
|
|
||||||
return metadata.summary ?? message.content
|
|
||||||
},
|
|
||||||
provider: quickQuestionProvider.rawValue,
|
|
||||||
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
|
||||||
model: quickQuestionModel,
|
|
||||||
models: quickQuestionProviderModelOptions,
|
|
||||||
isSending: isQuickQuestionSending,
|
|
||||||
isConverting: isConvertingQuickQuestion,
|
|
||||||
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
|
||||||
canConvert: canConvertQuickQuestion,
|
|
||||||
isAuthenticated: isAuthenticated,
|
|
||||||
isCheckingSession: isCheckingSession,
|
|
||||||
error: quickQuestionError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
// Scene lifetime is independent of focused commands: a connected window may
|
|
||||||
// still be authenticating, inactive, or minimized. The native Quick Question
|
|
||||||
// panel is not a workspace scene and does not count as an open window here.
|
|
||||||
@MainActor
|
|
||||||
public final class SybilMacWindowCommands: ObservableObject {
|
|
||||||
public static let shared = SybilMacWindowCommands()
|
|
||||||
|
|
||||||
@Published private var connectedWindowIDs: Set<String> = []
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
|
||||||
|
|
||||||
public func windowConnected(sessionID: String) {
|
|
||||||
connectedWindowIDs.insert(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func windowDisconnected(sessionID: String) {
|
|
||||||
connectedWindowIDs.remove(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
|
||||||
!hasOpenWindows || hasFocusedActions
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
|
||||||
if hasOpenWindows {
|
|
||||||
newChat?()
|
|
||||||
} else {
|
|
||||||
openWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -4,7 +4,6 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case 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 {
|
||||||
@@ -12,7 +11,6 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,8 +152,6 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
|||||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var parentChatId: String? = nil
|
|
||||||
public var titleGenerationPending = false
|
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -166,39 +162,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var lastUsedModel: String?
|
public var lastUsedModel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatSummary {
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case title
|
|
||||||
case parentChatId
|
|
||||||
case titleGenerationPending
|
|
||||||
case createdAt
|
|
||||||
case updatedAt
|
|
||||||
case starred
|
|
||||||
case starredAt
|
|
||||||
case initiatedProvider
|
|
||||||
case initiatedModel
|
|
||||||
case lastUsedProvider
|
|
||||||
case lastUsedModel
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
id = try container.decode(String.self, forKey: .id)
|
|
||||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
|
||||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
|
||||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
|
||||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
||||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
||||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
|
||||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
|
||||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
|
||||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
|
||||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
|
||||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -219,8 +182,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var query: String?
|
public var query: String?
|
||||||
public var parentChatId: String? = nil
|
|
||||||
public var titleGenerationPending = false
|
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -235,8 +196,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = chat.id
|
self.id = chat.id
|
||||||
self.title = chat.title
|
self.title = chat.title
|
||||||
self.query = nil
|
self.query = nil
|
||||||
self.parentChatId = chat.parentChatId
|
|
||||||
self.titleGenerationPending = chat.titleGenerationPending
|
|
||||||
self.createdAt = chat.createdAt
|
self.createdAt = chat.createdAt
|
||||||
self.updatedAt = chat.updatedAt
|
self.updatedAt = chat.updatedAt
|
||||||
self.starred = chat.starred
|
self.starred = chat.starred
|
||||||
@@ -252,8 +211,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = search.id
|
self.id = search.id
|
||||||
self.title = search.title
|
self.title = search.title
|
||||||
self.query = search.query
|
self.query = search.query
|
||||||
self.parentChatId = nil
|
|
||||||
self.titleGenerationPending = false
|
|
||||||
self.createdAt = search.createdAt
|
self.createdAt = search.createdAt
|
||||||
self.updatedAt = search.updatedAt
|
self.updatedAt = search.updatedAt
|
||||||
self.starred = search.starred
|
self.starred = search.starred
|
||||||
@@ -269,8 +226,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
return ChatSummary(
|
return ChatSummary(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
parentChatId: parentChatId,
|
|
||||||
titleGenerationPending: titleGenerationPending,
|
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
starred: starred,
|
starred: starred,
|
||||||
@@ -296,43 +251,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WorkspaceItem {
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case type
|
|
||||||
case id
|
|
||||||
case title
|
|
||||||
case query
|
|
||||||
case parentChatId
|
|
||||||
case titleGenerationPending
|
|
||||||
case createdAt
|
|
||||||
case updatedAt
|
|
||||||
case starred
|
|
||||||
case starredAt
|
|
||||||
case initiatedProvider
|
|
||||||
case initiatedModel
|
|
||||||
case lastUsedProvider
|
|
||||||
case lastUsedModel
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
type = try container.decode(WorkspaceItemType.self, forKey: .type)
|
|
||||||
id = try container.decode(String.self, forKey: .id)
|
|
||||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
|
||||||
query = try container.decodeIfPresent(String.self, forKey: .query)
|
|
||||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
|
||||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
|
||||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
||||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
||||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
|
||||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
|
||||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
|
||||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
|
||||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
|
||||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -471,8 +389,6 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var parentChatId: String? = nil
|
|
||||||
public var titleGenerationPending = false
|
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -484,41 +400,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var messages: [Message]
|
public var messages: [Message]
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatDetail {
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case title
|
|
||||||
case parentChatId
|
|
||||||
case titleGenerationPending
|
|
||||||
case createdAt
|
|
||||||
case updatedAt
|
|
||||||
case starred
|
|
||||||
case starredAt
|
|
||||||
case initiatedProvider
|
|
||||||
case initiatedModel
|
|
||||||
case lastUsedProvider
|
|
||||||
case lastUsedModel
|
|
||||||
case messages
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
id = try container.decode(String.self, forKey: .id)
|
|
||||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
|
||||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
|
||||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
|
||||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
||||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
||||||
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
|
||||||
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
|
||||||
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
|
||||||
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
|
||||||
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
|
||||||
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
|
||||||
messages = try container.decode([Message].self, forKey: .messages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ struct SybilQuickQuestionView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@FocusState private var promptFocused: Bool
|
@FocusState private var promptFocused: Bool
|
||||||
@State private var promptSelection: TextSelection?
|
|
||||||
|
|
||||||
private var hasAnswerContent: Bool {
|
private var hasAnswerContent: Bool {
|
||||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
@@ -37,8 +36,6 @@ struct SybilQuickQuestionView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
promptFocused = true
|
promptFocused = true
|
||||||
let prompt = viewModel.quickQuestionPrompt
|
|
||||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +94,6 @@ struct SybilQuickQuestionView: View {
|
|||||||
get: { viewModel.quickQuestionPrompt },
|
get: { viewModel.quickQuestionPrompt },
|
||||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
),
|
),
|
||||||
selection: $promptSelection,
|
|
||||||
axis: .vertical
|
axis: .vertical
|
||||||
)
|
)
|
||||||
.focused($promptFocused)
|
.focused($promptFocused)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
// A vertical TextField treats Return as editing, not form submission. Handle
|
|
||||||
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
|
||||||
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
|
||||||
@MainActor
|
|
||||||
enum SybilReturnKeySubmission {
|
|
||||||
static func handle(
|
|
||||||
phase: KeyPress.Phases,
|
|
||||||
modifiers: EventModifiers,
|
|
||||||
submit: () -> Void
|
|
||||||
) -> KeyPress.Result {
|
|
||||||
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
if phase == .down {
|
|
||||||
submit()
|
|
||||||
}
|
|
||||||
// Consume autorepeat without submitting or inserting a newline.
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
|
||||||
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
self
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,8 +98,7 @@ struct SybilSearchResultsView: View {
|
|||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
.frame(maxWidth: .infinity, 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,13 +11,11 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ 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
|
||||||
@@ -57,7 +54,6 @@ 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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -76,14 +72,12 @@ 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,10 +64,6 @@ 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,7 +160,6 @@ 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"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -406,17 +405,14 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
initiatedLabel = nil
|
initiatedLabel = nil
|
||||||
}
|
}
|
||||||
let starOwner = item.parentChatId.flatMap { parentChatID in
|
|
||||||
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
|
|
||||||
} ?? item
|
|
||||||
|
|
||||||
return SidebarItem(
|
return SidebarItem(
|
||||||
selection: .chat(item.id),
|
selection: .chat(item.id),
|
||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: item.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
starred: starOwner.starred,
|
starred: item.starred,
|
||||||
starredAt: starOwner.starredAt,
|
starredAt: item.starredAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -636,12 +632,9 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelQuickQuestion()
|
cancelQuickQuestion()
|
||||||
// Lock submission before the task starts, including repeated Return
|
|
||||||
// events delivered in the same main-actor turn.
|
|
||||||
isQuickQuestionSending = true
|
|
||||||
let selectedProvider = quickQuestionProvider
|
let selectedProvider = quickQuestionProvider
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
guard let self, !Task.isCancelled else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||||
@@ -693,8 +686,6 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
parentChatId: chat.parentChatId,
|
|
||||||
titleGenerationPending: chat.titleGenerationPending,
|
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -904,8 +895,7 @@ final class SybilViewModel {
|
|||||||
let client = try client()
|
let client = try client()
|
||||||
switch selection {
|
switch selection {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
let rootChatID = chatFamilyRootID(for: chatID)
|
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||||
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
|
||||||
applyChatSummary(updated, moveToFront: false)
|
applyChatSummary(updated, moveToFront: false)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||||
@@ -1162,11 +1152,13 @@ final class SybilViewModel {
|
|||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await client().runQuickQuestionStream(
|
try await client().runCompletionStream(
|
||||||
body: QuickQuestionStreamRequest(
|
body: CompletionStreamRequest(
|
||||||
|
chatId: nil,
|
||||||
|
persist: false,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model: model,
|
model: model,
|
||||||
question: prompt
|
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
||||||
)
|
)
|
||||||
) { [weak self] event in
|
) { [weak self] event in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -1461,8 +1453,6 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedChat?.id == chat.id {
|
if selectedChat?.id == chat.id {
|
||||||
selectedChat?.title = chat.title
|
selectedChat?.title = chat.title
|
||||||
selectedChat?.parentChatId = chat.parentChatId
|
|
||||||
selectedChat?.titleGenerationPending = chat.titleGenerationPending
|
|
||||||
selectedChat?.updatedAt = chat.updatedAt
|
selectedChat?.updatedAt = chat.updatedAt
|
||||||
selectedChat?.starred = chat.starred
|
selectedChat?.starred = chat.starred
|
||||||
selectedChat?.starredAt = chat.starredAt
|
selectedChat?.starredAt = chat.starredAt
|
||||||
@@ -1514,19 +1504,6 @@ final class SybilViewModel {
|
|||||||
workspaceItems.insert(item, at: 0)
|
workspaceItems.insert(item, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatFamilyRootID(for chatID: String) -> String {
|
|
||||||
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
|
|
||||||
return parentChatID
|
|
||||||
}
|
|
||||||
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
|
|
||||||
return parentChatID
|
|
||||||
}
|
|
||||||
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
|
||||||
return parentChatID
|
|
||||||
}
|
|
||||||
return chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attachToVisibleActiveRunIfNeeded() {
|
private func attachToVisibleActiveRunIfNeeded() {
|
||||||
guard draftKind == nil else {
|
guard draftKind == nil else {
|
||||||
return
|
return
|
||||||
@@ -1774,16 +1751,13 @@ 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,
|
||||||
@@ -1876,8 +1850,6 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
parentChatId: created.parentChatId,
|
|
||||||
titleGenerationPending: created.titleGenerationPending,
|
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
starred: created.starred,
|
starred: created.starred,
|
||||||
@@ -1936,7 +1908,7 @@ final class SybilViewModel {
|
|||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
let streamStartedWhileInactive = !isAppActive
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
if shouldRequestChatTitle(baseChat) {
|
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -2647,10 +2619,20 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
||||||
if chat.titleGenerationPending {
|
if let detail, detail.id == chatID {
|
||||||
|
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
|
||||||
|
if let summary = chats.first(where: { $0.id == chatID }) {
|
||||||
|
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,7 +601,6 @@ struct SybilWorkspaceView: View {
|
|||||||
.onSubmit {
|
.onSubmit {
|
||||||
submitComposer()
|
submitComposer()
|
||||||
}
|
}
|
||||||
.submitOnMacReturn(submitComposer)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
import Testing
|
import Testing
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
@@ -14,15 +10,11 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var updateChatTitle = 0
|
var updateChatTitle = 0
|
||||||
var suggestChatTitle = 0
|
|
||||||
var updateChatStar = 0
|
var updateChatStar = 0
|
||||||
var lastUpdateChatStarID: String?
|
|
||||||
var lastUpdateChatStarred: Bool?
|
|
||||||
var updateSearchStar = 0
|
var updateSearchStar = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
var runCompletionStream = 0
|
var runCompletionStream = 0
|
||||||
var runQuickQuestionStream = 0
|
|
||||||
var attachCompletionStream = 0
|
var attachCompletionStream = 0
|
||||||
var attachSearchStream = 0
|
var attachSearchStream = 0
|
||||||
}
|
}
|
||||||
@@ -44,14 +36,13 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
private let updateChatTitleResponses: [String: ChatSummary]
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
|
||||||
private let updateChatStarResponses: [String: ChatSummary]
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
private let updateSearchStarResponses: [String: SearchSummary]
|
private let updateSearchStarResponses: [String: SearchSummary]
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||||
@@ -73,7 +64,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
|
||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
@@ -86,7 +76,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
self.updateChatTitleResponses = updateChatTitleResponses
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
|
||||||
self.updateChatStarResponses = updateChatStarResponses
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
self.updateSearchStarResponses = updateSearchStarResponses
|
self.updateSearchStarResponses = updateSearchStarResponses
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
@@ -104,8 +93,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
lastCreateChatCall
|
lastCreateChatCall
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentQuickQuestionStreamBody() -> QuickQuestionStreamRequest? {
|
func currentCompletionStreamBody() -> CompletionStreamRequest? {
|
||||||
lastQuickQuestionStreamBody
|
lastCompletionStreamBody
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
||||||
@@ -215,8 +204,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||||
snapshot.updateChatStar += 1
|
snapshot.updateChatStar += 1
|
||||||
snapshot.lastUpdateChatStarID = chatID
|
|
||||||
snapshot.lastUpdateChatStarred = starred
|
|
||||||
guard let summary = updateChatStarResponses[chatID] else {
|
guard let summary = updateChatStarResponses[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -228,12 +215,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||||
snapshot.suggestChatTitle += 1
|
|
||||||
guard let summary = suggestChatTitleResponses[chatID] else {
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func listSearches() async throws -> [SearchSummary] {
|
func listSearches() async throws -> [SearchSummary] {
|
||||||
snapshot.listSearches += 1
|
snapshot.listSearches += 1
|
||||||
@@ -288,27 +271,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
snapshot.runCompletionStream += 1
|
snapshot.runCompletionStream += 1
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
lastCompletionStreamBody = body
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
|
||||||
}
|
|
||||||
if let completionStreamNetworkErrorMessage {
|
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
|
||||||
}
|
|
||||||
if let completionStreamEvents {
|
|
||||||
for event in completionStreamEvents {
|
|
||||||
await onEvent(event)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runQuickQuestionStream(
|
|
||||||
body: QuickQuestionStreamRequest,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
snapshot.runQuickQuestionStream += 1
|
|
||||||
lastQuickQuestionStreamBody = body
|
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
}
|
}
|
||||||
@@ -457,46 +420,6 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let legacySummary = try decoder.decode(
|
|
||||||
ChatSummary.self,
|
|
||||||
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
|
||||||
)
|
|
||||||
let legacyWorkspaceItem = try decoder.decode(
|
|
||||||
WorkspaceItem.self,
|
|
||||||
from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
|
||||||
)
|
|
||||||
let legacyDetail = try decoder.decode(
|
|
||||||
ChatDetail.self,
|
|
||||||
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
|
||||||
)
|
|
||||||
let forkDetail = try decoder.decode(
|
|
||||||
ChatDetail.self,
|
|
||||||
from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(legacySummary.parentChatId == nil)
|
|
||||||
#expect(!legacySummary.titleGenerationPending)
|
|
||||||
#expect(legacyWorkspaceItem.parentChatId == nil)
|
|
||||||
#expect(!legacyWorkspaceItem.titleGenerationPending)
|
|
||||||
#expect(legacyDetail.parentChatId == nil)
|
|
||||||
#expect(!legacyDetail.titleGenerationPending)
|
|
||||||
#expect(forkDetail.parentChatId == "root-chat")
|
|
||||||
#expect(forkDetail.titleGenerationPending)
|
|
||||||
|
|
||||||
var fork = legacySummary
|
|
||||||
fork.parentChatId = "root-chat"
|
|
||||||
fork.titleGenerationPending = true
|
|
||||||
|
|
||||||
let workspaceItem = WorkspaceItem(chat: fork)
|
|
||||||
let restoredSummary = try #require(workspaceItem.chatSummary)
|
|
||||||
#expect(workspaceItem.parentChatId == "root-chat")
|
|
||||||
#expect(workspaceItem.titleGenerationPending)
|
|
||||||
#expect(restoredSummary.parentChatId == "root-chat")
|
|
||||||
#expect(restoredSummary.titleGenerationPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||||
@@ -621,14 +544,12 @@ 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)
|
||||||
|
|
||||||
@@ -638,22 +559,24 @@ 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 == 0)
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||||
var original = makeChatSummary(id: "chat-rename", date: date)
|
let original = makeChatSummary(id: "chat-rename", date: date)
|
||||||
original.parentChatId = "root-chat"
|
let renamed = ChatSummary(
|
||||||
original.titleGenerationPending = true
|
id: "chat-rename",
|
||||||
var renamed = original
|
title: "Renamed chat",
|
||||||
renamed.title = "Renamed chat"
|
createdAt: date,
|
||||||
renamed.titleGenerationPending = false
|
updatedAt: date.addingTimeInterval(60),
|
||||||
renamed.updatedAt = date.addingTimeInterval(60)
|
initiatedProvider: .openai,
|
||||||
var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
initiatedModel: "gpt-4.1-mini",
|
||||||
detail.parentChatId = original.parentChatId
|
lastUsedProvider: .openai,
|
||||||
detail.titleGenerationPending = original.titleGenerationPending
|
lastUsedModel: "gpt-4.1-mini"
|
||||||
|
)
|
||||||
|
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||||
let client = MockSybilClient(
|
let client = MockSybilClient(
|
||||||
chatsResponse: [original],
|
chatsResponse: [original],
|
||||||
updateChatTitleResponses: ["chat-rename": renamed]
|
updateChatTitleResponses: ["chat-rename": renamed]
|
||||||
@@ -671,13 +594,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
#expect(snapshot.updateChatTitle == 1)
|
#expect(snapshot.updateChatTitle == 1)
|
||||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||||
#expect(viewModel.chats.first?.parentChatId == "root-chat")
|
|
||||||
#expect(viewModel.chats.first?.titleGenerationPending == false)
|
|
||||||
#expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
|
|
||||||
#expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
|
|
||||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||||
#expect(viewModel.selectedChat?.parentChatId == "root-chat")
|
|
||||||
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
#expect(viewModel.errorMessage == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,54 +633,6 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_180)
|
|
||||||
var root = makeChatSummary(id: "chat-root", date: date)
|
|
||||||
root.starred = true
|
|
||||||
root.starredAt = date.addingTimeInterval(5)
|
|
||||||
|
|
||||||
var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
|
|
||||||
child.parentChatId = root.id
|
|
||||||
child.titleGenerationPending = true
|
|
||||||
|
|
||||||
var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
|
|
||||||
childDetail.title = child.title
|
|
||||||
childDetail.parentChatId = root.id
|
|
||||||
childDetail.titleGenerationPending = true
|
|
||||||
|
|
||||||
var unstarredRoot = root
|
|
||||||
unstarredRoot.starred = false
|
|
||||||
unstarredRoot.starredAt = nil
|
|
||||||
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [root, child],
|
|
||||||
updateChatStarResponses: [root.id: unstarredRoot]
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.chats = [root, child]
|
|
||||||
viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
|
|
||||||
viewModel.selectedItem = .chat(child.id)
|
|
||||||
viewModel.selectedChat = childDetail
|
|
||||||
|
|
||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)
|
|
||||||
|
|
||||||
await viewModel.setItemStarred(.chat(child.id), starred: false)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.updateChatStar == 1)
|
|
||||||
#expect(snapshot.lastUpdateChatStarID == root.id)
|
|
||||||
#expect(snapshot.lastUpdateChatStarred == false)
|
|
||||||
#expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
|
|
||||||
#expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
|
|
||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
|
|
||||||
#expect(viewModel.selectedItem == .chat(child.id))
|
|
||||||
#expect(viewModel.selectedChat == childDetail)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
@@ -806,7 +675,6 @@ 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
|
||||||
@@ -911,144 +779,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_247)
|
|
||||||
var fork = makeChatSummary(id: "chat-fork", date: date)
|
|
||||||
fork.title = "Fork of Original chat"
|
|
||||||
fork.parentChatId = "root-chat"
|
|
||||||
fork.titleGenerationPending = true
|
|
||||||
|
|
||||||
var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
|
|
||||||
forkDetail.title = fork.title
|
|
||||||
forkDetail.parentChatId = fork.parentChatId
|
|
||||||
forkDetail.titleGenerationPending = true
|
|
||||||
|
|
||||||
var titledFork = fork
|
|
||||||
titledFork.title = "Investigating the follow-up"
|
|
||||||
titledFork.titleGenerationPending = false
|
|
||||||
titledFork.updatedAt = date.addingTimeInterval(1)
|
|
||||||
|
|
||||||
var titledForkDetail = forkDetail
|
|
||||||
titledForkDetail.title = titledFork.title
|
|
||||||
titledForkDetail.titleGenerationPending = false
|
|
||||||
titledForkDetail.updatedAt = titledFork.updatedAt
|
|
||||||
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [titledFork],
|
|
||||||
chatDetails: [fork.id: titledForkDetail],
|
|
||||||
suggestChatTitleResponses: [fork.id: titledFork]
|
|
||||||
)
|
|
||||||
await client.setCompletionStreamEvents(
|
|
||||||
[.done(CompletionStreamDone(text: "Follow-up answer"))],
|
|
||||||
delayNanoseconds: 100_000_000
|
|
||||||
)
|
|
||||||
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.chats = [fork]
|
|
||||||
viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
|
|
||||||
viewModel.selectedItem = .chat(fork.id)
|
|
||||||
viewModel.selectedChat = forkDetail
|
|
||||||
viewModel.composer = "Investigate this follow-up"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..<20 {
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
if snapshot.suggestChatTitle == 1,
|
|
||||||
viewModel.selectedChat?.title == titledFork.title,
|
|
||||||
viewModel.selectedChat?.titleGenerationPending == false {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 5_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
let titleSnapshot = await client.currentSnapshot()
|
|
||||||
#expect(titleSnapshot.suggestChatTitle == 1)
|
|
||||||
#expect(viewModel.chats.first?.title == titledFork.title)
|
|
||||||
#expect(viewModel.workspaceItems.first?.title == titledFork.title)
|
|
||||||
#expect(viewModel.selectedChat?.title == titledFork.title)
|
|
||||||
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
|
||||||
|
|
||||||
await sendTask.value
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
|
|
||||||
var submissions = 0
|
|
||||||
let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
|
|
||||||
let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
|
|
||||||
#expect(initial == .handled)
|
|
||||||
#expect(repeated == .handled)
|
|
||||||
#expect(submissions == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func composerModifiedReturnPreservesNativeEditing() {
|
|
||||||
var submissions = 0
|
|
||||||
for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
|
|
||||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
|
|
||||||
#expect(result == .ignored)
|
|
||||||
}
|
|
||||||
#expect(submissions == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func composerReturnStillSubmitsWithCapsLock() {
|
|
||||||
var submissions = 0
|
|
||||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
|
|
||||||
#expect(result == .handled)
|
|
||||||
#expect(submissions == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func quickQuestionHotKeyTogglesOncePerPress() {
|
|
||||||
var keyState = SybilQuickQuestionHotKeyState()
|
|
||||||
let keyDownEvents = [true, true, true, false, true, false, false, true]
|
|
||||||
let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
|
|
||||||
#expect(toggles == [true, false, false, false, true, false, false, true])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
|
|
||||||
let client = MockSybilClient()
|
|
||||||
await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.quickQuestionPrompt = "One question"
|
|
||||||
|
|
||||||
let first = viewModel.sendQuickQuestion()
|
|
||||||
let duplicate = viewModel.sendQuickQuestion()
|
|
||||||
#expect(first != nil)
|
|
||||||
#expect(duplicate == nil)
|
|
||||||
#expect(viewModel.isQuickQuestionSending)
|
|
||||||
await first?.value
|
|
||||||
|
|
||||||
let calls = await client.currentSnapshot()
|
|
||||||
#expect(calls.runQuickQuestionStream == 1)
|
|
||||||
#expect(calls.runCompletionStream == 0)
|
|
||||||
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
|
|
||||||
let client = MockSybilClient()
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.quickQuestionPrompt = "Do not send this"
|
|
||||||
|
|
||||||
let task = viewModel.sendQuickQuestion()
|
|
||||||
viewModel.cancelQuickQuestion()
|
|
||||||
await task?.value
|
|
||||||
|
|
||||||
let calls = await client.currentSnapshot()
|
|
||||||
#expect(calls.runQuickQuestionStream == 0)
|
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func quickQuestionUsesDedicatedServerEndpoint() async throws {
|
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
await client.setCompletionStreamEvents([
|
await client.setCompletionStreamEvents([
|
||||||
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
||||||
@@ -1063,11 +794,13 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
await task?.value
|
await task?.value
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
let body = await client.currentQuickQuestionStreamBody()
|
let body = await client.currentCompletionStreamBody()
|
||||||
#expect(snapshot.runQuickQuestionStream == 1)
|
#expect(snapshot.runCompletionStream == 1)
|
||||||
#expect(snapshot.runCompletionStream == 0)
|
#expect(body?.persist == false)
|
||||||
|
#expect(body?.chatId == nil)
|
||||||
#expect(body?.provider == .openai)
|
#expect(body?.provider == .openai)
|
||||||
#expect(body?.question == "How do I reset my password?")
|
#expect(body?.messages.first?.role == .user)
|
||||||
|
#expect(body?.messages.first?.content == "How do I reset my password?")
|
||||||
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
}
|
}
|
||||||
@@ -1314,365 +1047,3 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
@MainActor
|
|
||||||
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
|
|
||||||
let commands = SybilMacWindowCommands()
|
|
||||||
var chats = 0
|
|
||||||
var windows = 0
|
|
||||||
|
|
||||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
|
||||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
|
||||||
// A focused value can briefly outlive a closing scene. It must not route
|
|
||||||
// Command+N back into a closed workspace.
|
|
||||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
|
||||||
#expect(windows == 2)
|
|
||||||
#expect(chats == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macNewChatCommandUsesExistingWorkspace() {
|
|
||||||
let commands = SybilMacWindowCommands()
|
|
||||||
commands.windowConnected(sessionID: "first")
|
|
||||||
var chats = 0
|
|
||||||
var windows = 0
|
|
||||||
|
|
||||||
#expect(commands.canStartNewChat(hasFocusedActions: true))
|
|
||||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
|
||||||
#expect(chats == 1)
|
|
||||||
#expect(windows == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
|
|
||||||
let commands = SybilMacWindowCommands()
|
|
||||||
commands.windowConnected(sessionID: "authenticating")
|
|
||||||
var windows = 0
|
|
||||||
|
|
||||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
|
||||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
|
||||||
#expect(windows == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
|
|
||||||
let commands = SybilMacWindowCommands()
|
|
||||||
commands.windowConnected(sessionID: "first")
|
|
||||||
commands.windowConnected(sessionID: "first")
|
|
||||||
commands.windowConnected(sessionID: "second")
|
|
||||||
commands.windowDisconnected(sessionID: "first")
|
|
||||||
#expect(commands.hasOpenWindows)
|
|
||||||
commands.windowDisconnected(sessionID: "first")
|
|
||||||
#expect(commands.hasOpenWindows)
|
|
||||||
commands.windowDisconnected(sessionID: "second")
|
|
||||||
#expect(!commands.hasOpenWindows)
|
|
||||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
|
||||||
commands.windowConnected(sessionID: "third")
|
|
||||||
#expect(commands.hasOpenWindows)
|
|
||||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
|
|
||||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
|
||||||
var isVisible = false
|
|
||||||
var state = SybilQuickQuestionPanelState()
|
|
||||||
var presentationCount = 0
|
|
||||||
var activationCount = 0
|
|
||||||
var stateUpdateCount = 0
|
|
||||||
var events: [String] = []
|
|
||||||
var onShow: (() -> Void)?
|
|
||||||
|
|
||||||
required override init() { super.init() }
|
|
||||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
|
||||||
self.delegate = delegate
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
func updateState(_ data: Data) {
|
|
||||||
state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
|
|
||||||
stateUpdateCount += 1
|
|
||||||
events.append("update")
|
|
||||||
}
|
|
||||||
func show() {
|
|
||||||
onShow?()
|
|
||||||
isVisible = true
|
|
||||||
presentationCount += 1
|
|
||||||
events.append("show")
|
|
||||||
}
|
|
||||||
func hide() {
|
|
||||||
isVisible = false
|
|
||||||
delegate?.quickQuestionPanelDismissed()
|
|
||||||
}
|
|
||||||
func activateForQuickQuestion() {
|
|
||||||
activationCount += 1
|
|
||||||
events.append("activate")
|
|
||||||
}
|
|
||||||
func activateMainWindow() {}
|
|
||||||
func stop() { hide() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
|
|
||||||
for _ in 0..<100 {
|
|
||||||
if condition() { return }
|
|
||||||
try await Task.sleep(for: .milliseconds(2))
|
|
||||||
}
|
|
||||||
try #require(condition())
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockQuickQuestionLifecycle {
|
|
||||||
var isForeground = false
|
|
||||||
let notifications = NotificationCenter()
|
|
||||||
|
|
||||||
func notify(_ name: Notification.Name) {
|
|
||||||
notifications.post(name: name, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
|
|
||||||
let lifecycle = MockQuickQuestionLifecycle()
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
|
||||||
viewModel.quickQuestionPrompt = "Original prompt"
|
|
||||||
let controller = SybilMacQuickQuestionController(
|
|
||||||
bridge: panel,
|
|
||||||
isApplicationInForeground: { lifecycle.isForeground },
|
|
||||||
notificationCenter: lifecycle.notifications
|
|
||||||
)
|
|
||||||
defer { controller.stop() }
|
|
||||||
controller.attach(viewModel)
|
|
||||||
panel.onShow = { #expect(lifecycle.isForeground) }
|
|
||||||
|
|
||||||
controller.toggle()
|
|
||||||
// Never create/order a window inline in the hotkey's event callback.
|
|
||||||
#expect(panel.presentationCount == 0)
|
|
||||||
#expect(panel.activationCount == 0)
|
|
||||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
|
||||||
#expect(panel.stateUpdateCount == 0)
|
|
||||||
#expect(!panel.isVisible)
|
|
||||||
|
|
||||||
// "Will enter" is not proof of readiness: UIKit can still be backgrounded.
|
|
||||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
|
||||||
#expect(panel.activationCount == 1)
|
|
||||||
#expect(panel.presentationCount == 0)
|
|
||||||
#expect(panel.stateUpdateCount == 0)
|
|
||||||
|
|
||||||
viewModel.quickQuestionPrompt = "The latest prompt while waiting"
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.events == ["activate", "update", "show"])
|
|
||||||
#expect(panel.state.prompt == "The latest prompt while waiting")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
|
|
||||||
let lifecycle = MockQuickQuestionLifecycle()
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(
|
|
||||||
bridge: panel,
|
|
||||||
isApplicationInForeground: { lifecycle.isForeground },
|
|
||||||
notificationCenter: lifecycle.notifications
|
|
||||||
)
|
|
||||||
defer { controller.stop() }
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
|
||||||
controller.toggle()
|
|
||||||
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
|
||||||
#expect(!panel.isVisible)
|
|
||||||
#expect(panel.presentationCount == 0)
|
|
||||||
#expect(panel.stateUpdateCount == 0)
|
|
||||||
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.presentationCount == 1)
|
|
||||||
#expect(panel.activationCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
|
|
||||||
let lifecycle = MockQuickQuestionLifecycle()
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(
|
|
||||||
bridge: panel,
|
|
||||||
isApplicationInForeground: { lifecycle.isForeground },
|
|
||||||
notificationCenter: lifecycle.notifications
|
|
||||||
)
|
|
||||||
defer { controller.stop() }
|
|
||||||
controller.toggle()
|
|
||||||
controller.toggle()
|
|
||||||
controller.toggle()
|
|
||||||
#expect(panel.presentationCount == 0)
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.presentationCount == 1)
|
|
||||||
#expect(panel.activationCount == 0)
|
|
||||||
|
|
||||||
// Lifecycle notifications must not present/select the prompt a second time.
|
|
||||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
|
||||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
|
||||||
#expect(panel.presentationCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
|
|
||||||
let lifecycle = MockQuickQuestionLifecycle()
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(
|
|
||||||
bridge: panel,
|
|
||||||
isApplicationInForeground: { lifecycle.isForeground },
|
|
||||||
notificationCenter: lifecycle.notifications
|
|
||||||
)
|
|
||||||
controller.show()
|
|
||||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
|
||||||
controller.stop()
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
|
||||||
#expect(panel.presentationCount == 0)
|
|
||||||
#expect(panel.stateUpdateCount == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
|
|
||||||
let lifecycle = MockQuickQuestionLifecycle()
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(
|
|
||||||
bridge: panel,
|
|
||||||
isApplicationInForeground: { lifecycle.isForeground },
|
|
||||||
notificationCenter: lifecycle.notifications
|
|
||||||
)
|
|
||||||
defer { controller.stop() }
|
|
||||||
controller.show()
|
|
||||||
lifecycle.isForeground = false
|
|
||||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
|
||||||
#expect(!panel.isVisible)
|
|
||||||
#expect(panel.stateUpdateCount == 0)
|
|
||||||
lifecycle.isForeground = true
|
|
||||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.presentationCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
|
|
||||||
let client = MockSybilClient()
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.quickQuestionPrompt = "Keep this draft"
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
|
||||||
controller.attach(viewModel)
|
|
||||||
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.isVisible)
|
|
||||||
#expect(panel.state.prompt == "Keep this draft")
|
|
||||||
#expect(panel.state.canSend)
|
|
||||||
controller.toggle()
|
|
||||||
#expect(!panel.isVisible)
|
|
||||||
|
|
||||||
viewModel.quickQuestionMessages = [
|
|
||||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
|
|
||||||
]
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.isVisible)
|
|
||||||
#expect(panel.presentationCount == 2)
|
|
||||||
#expect(panel.state.prompt == "Keep this draft")
|
|
||||||
#expect(panel.state.answer == "An answer arrived while hidden.")
|
|
||||||
let calls = await client.currentSnapshot()
|
|
||||||
#expect(calls.runQuickQuestionStream == 0)
|
|
||||||
controller.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.quickQuestionPrompt = "Original window"
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
|
||||||
controller.attach(viewModel)
|
|
||||||
controller.show()
|
|
||||||
|
|
||||||
let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
|
|
||||||
otherWindow.quickQuestionPrompt = "Other window"
|
|
||||||
controller.attach(otherWindow)
|
|
||||||
viewModel.quickQuestionMessages = [
|
|
||||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
|
|
||||||
]
|
|
||||||
for _ in 0..<30 {
|
|
||||||
if panel.state.answer == "Streaming update" { break }
|
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
|
||||||
}
|
|
||||||
#expect(panel.state.answer == "Streaming update")
|
|
||||||
#expect(panel.state.prompt == "Original window")
|
|
||||||
controller.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
|
|
||||||
let client = MockSybilClient()
|
|
||||||
var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
windowViewModel?.isAuthenticated = true
|
|
||||||
windowViewModel?.isCheckingSession = false
|
|
||||||
windowViewModel?.quickQuestionPrompt = "A question from the closed window"
|
|
||||||
windowViewModel?.quickQuestionMessages = [
|
|
||||||
Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
|
|
||||||
]
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
|
||||||
controller.attach(try #require(windowViewModel))
|
|
||||||
|
|
||||||
// Releasing the workspace must not release the global shortcut's model.
|
|
||||||
windowViewModel = nil
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.isVisible)
|
|
||||||
#expect(panel.state.prompt == "A question from the closed window")
|
|
||||||
#expect(panel.state.answer == "Keep this answer too.")
|
|
||||||
controller.toggle()
|
|
||||||
#expect(!panel.isVisible)
|
|
||||||
controller.toggle()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
#expect(panel.presentationCount == 2)
|
|
||||||
#expect(panel.state.canSend)
|
|
||||||
let calls = await client.currentSnapshot()
|
|
||||||
#expect(calls.runQuickQuestionStream == 0)
|
|
||||||
controller.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
|
|
||||||
let client = MockSybilClient()
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = false
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
let panel = MockQuickQuestionPanel()
|
|
||||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
|
||||||
controller.attach(viewModel)
|
|
||||||
controller.show()
|
|
||||||
try await waitForQuickQuestion { panel.isVisible }
|
|
||||||
controller.quickQuestionPromptChanged("A signed-out question")
|
|
||||||
controller.quickQuestionSubmitRequested()
|
|
||||||
|
|
||||||
#expect(!panel.state.canSend)
|
|
||||||
#expect(panel.state.prompt == "A signed-out question")
|
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
|
||||||
let calls = await client.currentSnapshot()
|
|
||||||
#expect(calls.runQuickQuestionStream == 0)
|
|
||||||
controller.stop()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Sybil for iOS and Mac Catalyst
|
|
||||||
|
|
||||||
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
|
||||||
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
|
|
||||||
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
|
||||||
release/archive signing settings are unchanged.
|
|
||||||
|
|
||||||
## Quick Question on macOS
|
|
||||||
|
|
||||||
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
|
||||||
Question panel, including over other applications. The prompt is focused as soon
|
|
||||||
as it opens, with any existing prompt selected so typing replaces it. The same
|
|
||||||
select-on-open behavior applies to Quick Question on iPhone and iPad.
|
|
||||||
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
|
|
||||||
second **Option+Space**, the close button, or clicking outside dismisses it.
|
|
||||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
|
||||||
**Open in Chat** saves the question and answer in the regular workspace.
|
|
||||||
|
|
||||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
|
||||||
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
|
||||||
|
|
||||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
|
||||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
|
||||||
build opts out of automatic termination, and the native shortcut service holds a
|
|
||||||
matching automatic-termination activity until it stops. No hidden keep-alive
|
|
||||||
window or sleep-preventing activity is used. Mac scene support also allows the
|
|
||||||
workspace to reopen after its last window is closed.
|
|
||||||
|
|
||||||
When Catalyst is backgrounded, opening Quick Question first requests app
|
|
||||||
activation and waits for UIKit to enter the foreground before creating or ordering
|
|
||||||
the native panel. Presentation is deferred out of the hotkey event callback.
|
|
||||||
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
|
|
||||||
that required activation returns focus to the previous app; explicitly opening
|
|
||||||
the workspace keeps focus in Sybil.
|
|
||||||
|
|
||||||
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
|
|
||||||
workspace window when none are open. **Command+Shift+N** always opens a new window,
|
|
||||||
including while signed out. **Command+Option+N** starts a new search; iOS retains
|
|
||||||
its existing Command+Shift+N search shortcut.
|
|
||||||
|
|
||||||
The shortcut is also shown under **File → Quick Question**. If another application
|
|
||||||
already owns Option+Space, the panel reports that the shortcut is unavailable;
|
|
||||||
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
|
|
||||||
needed for the registered hotkey.
|
|
||||||
|
|
||||||
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
|
|
||||||
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
|
|
||||||
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
|
|
||||||
provider/model preferences, streaming, and conversion to chat remain in the
|
|
||||||
existing `SybilViewModel`; there is no separate backend or API contract. The panel
|
|
||||||
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
|
|
||||||
Catalyst process.
|
|
||||||
|
|
||||||
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
|
|
||||||
action, and does not build or embed the macOS helper.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `just test`: existing Swift package tests on the iPhone simulator.
|
|
||||||
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
|
|
||||||
- For a manual Mac check, open the panel from another app, type immediately,
|
|
||||||
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
|
|
||||||
returns input to the previous app. Check a second display and a full-screen Space
|
|
||||||
when available.
|
|
||||||
- Close every Mac window, invoke Option+Space, and verify the saved question and
|
|
||||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
|
||||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
|
||||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
|
||||||
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
|
|
||||||
- Check Command+N both with a workspace open and after closing every workspace.
|
|
||||||
Command+Shift+N must open another window even when one already exists, and
|
|
||||||
Command+Option+N must start a search without opening a window.
|
|
||||||
- With all workspace windows closed, switch to another app and repeatedly open
|
|
||||||
and dismiss Quick Question, including two quick Option+Space presses while it
|
|
||||||
is opening. Check that no late panel appears after cancellation and that no
|
|
||||||
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
|
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
app_identifier("net.buzzert.sybil2")
|
require "dotenv"
|
||||||
team_id("DQQH5H6GBD")
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# TestFlight Release CI
|
||||||
|
|
||||||
|
Gitea Actions publishes iOS releases from tags that match:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
release/vN.N.N
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag release/v1.10.0
|
||||||
|
git push origin release/v1.10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The release job runs on the `xcode` runner label, creates the runner user's
|
||||||
|
login keychain from Gitea secrets, makes that keychain the user default for the
|
||||||
|
duration of the job, installs the App Store provisioning profile in both the
|
||||||
|
legacy MobileDevice directory and the Xcode UserData directory used by newer
|
||||||
|
Xcode releases, builds and uploads the app with fastlane, then creates or
|
||||||
|
updates the matching Gitea release with the generated IPA as an asset. The job
|
||||||
|
restores the previous user default keychain and deletes the user login keychain
|
||||||
|
and installed profiles in an `always()` cleanup step. No signing material is
|
||||||
|
installed into the system keychain.
|
||||||
|
|
||||||
|
Required repository secrets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT
|
||||||
|
APPSTORE_CERTIFICATES_FILE_BASE64
|
||||||
|
APPSTORE_CERTIFICATES_PASSWORD
|
||||||
|
APPSTORE_PROVISIONING_PROFILE_BASE64
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate or refresh the signing assets locally with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ios
|
||||||
|
fastlane ios create_ci_signing
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
|
||||||
|
certificate and provisioning profile values into the repository secrets listed
|
||||||
|
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
|
||||||
|
default.
|
||||||
|
|
||||||
|
Fastlane keeps two signing names separate. `SYBIL_CODE_SIGN_IDENTITY` is the
|
||||||
|
exact certificate common name used when exporting a local p12 for secrets, while
|
||||||
|
`SYBIL_XCODE_CODE_SIGN_IDENTITY` defaults to the certificate SHA-1 fingerprint
|
||||||
|
that Xcode uses during archive. `SYBIL_EXPORT_SIGNING_CERTIFICATE` defaults to
|
||||||
|
the generic `Apple Distribution` selector used in the export options.
|
||||||
|
|
||||||
|
The Release signing settings are also present in `Apps/Sybil/project.yml` so
|
||||||
|
XcodeGen emits a manually signed App Store archive configuration. CI passes the
|
||||||
|
installed provisioning profile UUID to Fastlane as
|
||||||
|
`SYBIL_PROVISIONING_PROFILE_UUID`; Fastlane writes that UUID into the generated
|
||||||
|
project before archiving. CI also passes the temporary keychain path as
|
||||||
|
`CODE_SIGN_KEYCHAIN` so Xcode searches the disposable keychain for the imported
|
||||||
|
Distribution identity.
|
||||||
|
|
||||||
|
If the Apple team has reached the Distribution certificate limit, set
|
||||||
|
`SYBIL_SIGNING_CERTIFICATE_ID` to the portal id for a certificate whose private
|
||||||
|
key exists in the local login keychain before running `create_ci_signing`. The
|
||||||
|
lane will export the local identity and create the provisioning profile against
|
||||||
|
that existing certificate instead of creating another Distribution certificate.
|
||||||
|
|
||||||
|
If `create_ci_signing` fails with an expired or missing agreement error, the
|
||||||
|
Apple Developer Program account holder must accept the current agreements in
|
||||||
|
App Store Connect before new certificates or provisioning profiles can be
|
||||||
|
created through the API.
|
||||||
|
|
||||||
|
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
|
||||||
|
upload, with `contents: write` permissions. In Gitea this covers release asset
|
||||||
|
publication.
|
||||||
+461
-29
@@ -1,47 +1,479 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "base64"
|
||||||
|
require "fileutils"
|
||||||
|
require "json"
|
||||||
|
require "net/http"
|
||||||
|
require "open3"
|
||||||
|
require "openssl"
|
||||||
|
require "securerandom"
|
||||||
|
require "shellwords"
|
||||||
|
require "uri"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
platform :ios do
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
desc "Build a release tag and upload it to TestFlight"
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
lane :beta do
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
setup_ci
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
|
||||||
|
SIGNING_CERTIFICATE_NAME = ENV["SYBIL_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution: James Magahern (DQQH5H6GBD)" : ENV["SYBIL_CODE_SIGN_IDENTITY"]
|
||||||
|
XCODE_CODE_SIGN_IDENTITY = ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "6B74B268C4761720FB2051D01D8BB3E47B55D9F5" : ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"]
|
||||||
|
EXPORT_SIGNING_CERTIFICATE = ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"].to_s.strip.empty? ? "Apple Distribution" : ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"]
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
match(type: "appstore")
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
tag = ENV.fetch("GITHUB_REF_NAME")
|
def capture(command)
|
||||||
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
end
|
||||||
|
|
||||||
ios_dir = File.expand_path("..", __dir__)
|
def run_silent(*command, error_message:)
|
||||||
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
|
_stdout, stderr, status = Open3.capture3(*command)
|
||||||
contents = File.read(project_spec)
|
return if status.success?
|
||||||
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
|
|
||||||
UI.user_error!("Could not find version settings in #{project_spec}")
|
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
|
||||||
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
end
|
||||||
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
|
project.save
|
||||||
File.write(project_spec, contents)
|
end
|
||||||
|
|
||||||
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
api_key = app_store_connect_api_key(
|
def local_build_number
|
||||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
end
|
||||||
key_content: ENV.fetch("ASC_KEY"),
|
|
||||||
is_key_content_base64: true
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(%r{\Arelease/}, "").sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+\.\d+\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like release/v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key.to_s.shellescape}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def env_line(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def base64url(value)
|
||||||
|
Base64.urlsafe_encode64(value).delete("=")
|
||||||
|
end
|
||||||
|
|
||||||
|
def integer_to_fixed_bytes(integer, length)
|
||||||
|
hex = integer.to_s(16)
|
||||||
|
hex = "0#{hex}" if hex.length.odd?
|
||||||
|
[hex].pack("H*").rjust(length, "\0")[-length, length]
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_private_key
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
|
||||||
|
pem = if present?(key_path)
|
||||||
|
File.read(key_path)
|
||||||
|
elsif present?(key_content)
|
||||||
|
ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true" ? Base64.decode64(key_content) : key_content
|
||||||
|
end
|
||||||
|
UI.user_error!("App Store Connect API key content is required") unless present?(pem)
|
||||||
|
|
||||||
|
OpenSSL::PKey::EC.new(pem)
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_jwt
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||||
|
UI.user_error!("App Store Connect API key id and issuer id are required") unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
header = { alg: "ES256", kid: key_id, typ: "JWT" }
|
||||||
|
payload = { iss: issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + 600, aud: "appstoreconnect-v1" }
|
||||||
|
unsigned = [base64url(header.to_json), base64url(payload.to_json)].join(".")
|
||||||
|
asn1_signature = app_store_connect_private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(unsigned))
|
||||||
|
signature_sequence = OpenSSL::ASN1.decode(asn1_signature)
|
||||||
|
raw_signature = signature_sequence.value.map { |part| integer_to_fixed_bytes(part.value, 32) }.join
|
||||||
|
[unsigned, base64url(raw_signature)].join(".")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_request(method, path, payload = nil)
|
||||||
|
uri = URI("https://api.appstoreconnect.apple.com#{path}")
|
||||||
|
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
||||||
|
request = request_class.new(uri)
|
||||||
|
request["Authorization"] = "Bearer #{app_store_connect_jwt}"
|
||||||
|
if payload
|
||||||
|
request["Content-Type"] = "application/json"
|
||||||
|
request.body = payload.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
||||||
|
return {} if response.is_a?(Net::HTTPSuccess) && response.body.to_s.empty?
|
||||||
|
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
UI.user_error!("App Store Connect API request failed: #{method.to_s.upcase} #{path}\n#{response.body}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def bundle_id_resource_id
|
||||||
|
response = app_store_connect_request(
|
||||||
|
:get,
|
||||||
|
"/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1"
|
||||||
|
)
|
||||||
|
id = response.fetch("data", []).first&.fetch("id", nil)
|
||||||
|
UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id)
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
def recreate_app_store_profile(certificate_id)
|
||||||
|
existing = app_store_connect_request(
|
||||||
|
:get,
|
||||||
|
"/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200"
|
||||||
|
)
|
||||||
|
existing.fetch("data", []).each do |profile|
|
||||||
|
app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}")
|
||||||
|
end
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
data: {
|
||||||
|
type: "profiles",
|
||||||
|
attributes: {
|
||||||
|
name: PROFILE_SPECIFIER,
|
||||||
|
profileType: "IOS_APP_STORE"
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
bundleId: {
|
||||||
|
data: { type: "bundleIds", id: bundle_id_resource_id }
|
||||||
|
},
|
||||||
|
certificates: {
|
||||||
|
data: [{ type: "certificates", id: certificate_id }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = app_store_connect_request(:post, "/v1/profiles", payload)
|
||||||
|
profile_content = response.dig("data", "attributes", "profileContent")
|
||||||
|
UI.user_error!("App Store Connect profile response did not include profileContent") unless present?(profile_content)
|
||||||
|
|
||||||
|
profile_path = File.join(SIGNING_OUTPUT_DIR, "Sybil_AppStore_CI.mobileprovision")
|
||||||
|
File.binwrite(profile_path, Base64.decode64(profile_content))
|
||||||
|
install_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
|
||||||
|
FileUtils.mkdir_p(install_dir)
|
||||||
|
FileUtils.cp(profile_path, File.join(install_dir, "Sybil_AppStore_CI.mobileprovision"))
|
||||||
|
profile_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def signing_identity_p12(p12_path, p12_password)
|
||||||
|
work_dir = File.join(SIGNING_OUTPUT_DIR, "single-identity")
|
||||||
|
FileUtils.rm_rf(work_dir)
|
||||||
|
FileUtils.mkdir_p(work_dir)
|
||||||
|
|
||||||
|
all_pem = File.join(work_dir, "all.pem")
|
||||||
|
stdout, stderr, status = Open3.capture3(
|
||||||
|
"openssl", "pkcs12",
|
||||||
|
"-in", p12_path,
|
||||||
|
"-nodes",
|
||||||
|
"-passin", "pass:#{p12_password}",
|
||||||
|
"-out", all_pem
|
||||||
|
)
|
||||||
|
UI.user_error!("Could not inspect exported p12\n#{stderr}\n#{stdout}") unless status.success?
|
||||||
|
|
||||||
|
records = File.read(all_pem).split(/(?=Bag Attributes\n)/)
|
||||||
|
cert_record = records.find do |record|
|
||||||
|
record.include?("friendlyName: #{SIGNING_CERTIFICATE_NAME}") && record.include?("-----BEGIN CERTIFICATE-----")
|
||||||
|
end
|
||||||
|
UI.user_error!("Could not find #{SIGNING_CERTIFICATE_NAME} certificate in exported p12") unless cert_record
|
||||||
|
|
||||||
|
local_key_id = cert_record[/localKeyID: (.+)/, 1].to_s.strip
|
||||||
|
UI.user_error!("Could not resolve localKeyID for #{SIGNING_CERTIFICATE_NAME}") unless present?(local_key_id)
|
||||||
|
|
||||||
|
key_record = records.find do |record|
|
||||||
|
record.include?("localKeyID: #{local_key_id}") && record.include?("-----BEGIN PRIVATE KEY-----")
|
||||||
|
end
|
||||||
|
UI.user_error!("Could not find private key for #{SIGNING_CERTIFICATE_NAME}") unless key_record
|
||||||
|
|
||||||
|
cert_path = File.join(work_dir, "identity.cer.pem")
|
||||||
|
key_path = File.join(work_dir, "identity.key.pem")
|
||||||
|
File.write(cert_path, cert_record[/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m])
|
||||||
|
File.write(key_path, key_record[/-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----/m])
|
||||||
|
|
||||||
|
root_cer = File.join(work_dir, "AppleIncRootCertificate.cer")
|
||||||
|
wwdr_cer = File.join(work_dir, "AppleWWDRCAG3.cer")
|
||||||
|
root_pem = File.join(work_dir, "AppleIncRootCertificate.pem")
|
||||||
|
wwdr_pem = File.join(work_dir, "AppleWWDRCAG3.pem")
|
||||||
|
chain_pem = File.join(work_dir, "chain.pem")
|
||||||
|
run_silent("curl", "-fsSL", "https://www.apple.com/appleca/AppleIncRootCertificate.cer", "-o", root_cer, error_message: "Could not download Apple root certificate")
|
||||||
|
run_silent("curl", "-fsSL", "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer", "-o", wwdr_cer, error_message: "Could not download Apple WWDR G3 certificate")
|
||||||
|
run_silent("openssl", "x509", "-inform", "DER", "-in", root_cer, "-out", root_pem, error_message: "Could not convert Apple root certificate")
|
||||||
|
run_silent("openssl", "x509", "-inform", "DER", "-in", wwdr_cer, "-out", wwdr_pem, error_message: "Could not convert Apple WWDR G3 certificate")
|
||||||
|
File.write(chain_pem, File.read(wwdr_pem) + File.read(root_pem))
|
||||||
|
|
||||||
|
single_p12_path = File.join(work_dir, "appstore-signing.p12")
|
||||||
|
run_silent(
|
||||||
|
"openssl", "pkcs12", "-export",
|
||||||
|
"-inkey", key_path,
|
||||||
|
"-in", cert_path,
|
||||||
|
"-certfile", chain_pem,
|
||||||
|
"-name", SIGNING_CERTIFICATE_NAME,
|
||||||
|
"-out", single_p12_path,
|
||||||
|
"-passout", "pass:#{p12_password}",
|
||||||
|
error_message: "Could not create single-identity p12"
|
||||||
|
)
|
||||||
|
FileUtils.cp(single_p12_path, p12_path)
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_rf(work_dir) if present?(work_dir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
private_lane :load_app_store_connect_api_key do
|
||||||
|
options = app_store_connect_key_options
|
||||||
|
UI.user_error!("App Store Connect API key is required") unless options
|
||||||
|
|
||||||
|
app_store_connect_api_key(options)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Create CI signing certificate/profile and write ignored secret material under build/signing"
|
||||||
|
lane :create_ci_signing do
|
||||||
|
api_key = load_app_store_connect_api_key
|
||||||
|
|
||||||
|
FileUtils.rm_rf(SIGNING_OUTPUT_DIR)
|
||||||
|
FileUtils.mkdir_p(SIGNING_OUTPUT_DIR)
|
||||||
|
|
||||||
|
cert_id = ENV["SYBIL_SIGNING_CERTIFICATE_ID"].to_s
|
||||||
|
keychain_path = nil
|
||||||
|
keychain_password = nil
|
||||||
|
p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12")
|
||||||
|
p12_password = ENV["SYBIL_CI_P12_PASSWORD"].to_s
|
||||||
|
if p12_password.empty?
|
||||||
|
p12_password = SecureRandom.base64(24)
|
||||||
|
UI.important("Generated a p12 password for CI secrets.")
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
if present?(cert_id)
|
||||||
|
UI.message("Using existing signing certificate id #{cert_id}")
|
||||||
|
export_keychain = ENV["SYBIL_SIGNING_KEYCHAIN"].to_s
|
||||||
|
export_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") unless present?(export_keychain)
|
||||||
|
run_silent(
|
||||||
|
"security", "export", "-k", export_keychain, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||||
|
error_message: "Could not export the local CI signing identity"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db")
|
||||||
|
keychain_password = SecureRandom.base64(24)
|
||||||
|
run_silent(
|
||||||
|
"security", "create-keychain", "-p", keychain_password, keychain_path,
|
||||||
|
error_message: "Could not create temporary signing keychain"
|
||||||
|
)
|
||||||
|
run_silent(
|
||||||
|
"security", "set-keychain-settings", "-lut", "21600", keychain_path,
|
||||||
|
error_message: "Could not configure temporary signing keychain"
|
||||||
|
)
|
||||||
|
run_silent(
|
||||||
|
"security", "unlock-keychain", "-p", keychain_password, keychain_path,
|
||||||
|
error_message: "Could not unlock temporary signing keychain"
|
||||||
|
)
|
||||||
|
run_silent(
|
||||||
|
"security", "list-keychains", "-d", "user", "-s", keychain_path, *user_keychains,
|
||||||
|
error_message: "Could not add temporary signing keychain to the user search list"
|
||||||
)
|
)
|
||||||
|
|
||||||
build_app(
|
cert(
|
||||||
project: File.join(ios_dir, "Sybil.xcodeproj"),
|
api_key: api_key,
|
||||||
scheme: "Sybil"
|
development: false,
|
||||||
|
force: true,
|
||||||
|
generate_apple_certs: true,
|
||||||
|
keychain_password: keychain_password,
|
||||||
|
keychain_path: keychain_path,
|
||||||
|
output_path: SIGNING_OUTPUT_DIR,
|
||||||
|
platform: "ios"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID]
|
||||||
|
UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id)
|
||||||
|
run_silent(
|
||||||
|
"security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
|
||||||
|
error_message: "Could not export the generated CI signing identity"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
|
||||||
|
signing_identity_p12(p12_path, p12_password)
|
||||||
|
|
||||||
|
profile_path = recreate_app_store_profile(cert_id)
|
||||||
|
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
|
||||||
|
|
||||||
|
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
|
||||||
|
File.write(
|
||||||
|
secrets_path,
|
||||||
|
[
|
||||||
|
env_line("APPSTORE_CERTIFICATES_FILE_BASE64", Base64.strict_encode64(File.binread(p12_path))),
|
||||||
|
env_line("APPSTORE_CERTIFICATES_PASSWORD", p12_password),
|
||||||
|
env_line("APPSTORE_PROVISIONING_PROFILE_BASE64", Base64.strict_encode64(File.binread(profile_path))),
|
||||||
|
env_line("SYBIL_PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER)
|
||||||
|
].join("\n") + "\n"
|
||||||
|
)
|
||||||
|
ensure
|
||||||
|
system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if present?(keychain_path) && File.exist?(keychain_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
|
||||||
|
UI.important("Add the values from #{secrets_path} as repository secrets.")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = load_app_store_connect_api_key
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
apply_release_signing_settings
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
]
|
||||||
|
if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
|
||||||
|
xcode_args << xcode_build_setting("CODE_SIGN_KEYCHAIN", ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH"))
|
||||||
|
xcode_args << xcode_build_setting("OTHER_CODE_SIGN_FLAGS", "--keychain #{ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH")}")
|
||||||
|
end
|
||||||
|
xcode_args = xcode_args.join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_options: {
|
||||||
|
method: "app-store",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "manual",
|
||||||
|
provisioningProfiles: {
|
||||||
|
APP_IDENTIFIER => PROFILE_SPECIFIER
|
||||||
|
},
|
||||||
|
signingCertificate: EXPORT_SIGNING_CERTIFICATE,
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
skip_waiting_for_build_processing: true,
|
app_identifier: APP_IDENTIFIER,
|
||||||
uses_non_exempt_encryption: false
|
ipa: ipa_path,
|
||||||
|
skip_waiting_for_build_processing: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
|
|
||||||
storage_mode("git")
|
|
||||||
type("appstore")
|
|
||||||
|
|
||||||
app_identifier(["net.buzzert.sybil2"])
|
|
||||||
team_id("DQQH5H6GBD")
|
|
||||||
profile_name("Sybil AppStore CI")
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios create_ci_signing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios create_ci_signing
|
||||||
|
```
|
||||||
|
|
||||||
|
Create CI signing certificate/profile and write ignored secret material under build/signing
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
@@ -24,12 +24,6 @@ run: generate
|
|||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
build-mac: generate
|
|
||||||
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
|
||||||
|
|
||||||
run-mac: build-mac
|
|
||||||
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
|
||||||
|
|
||||||
beta:
|
beta:
|
||||||
fastlane ios beta
|
fastlane ios beta
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ packages:
|
|||||||
path: Packages/Sybil
|
path: Packages/Sybil
|
||||||
include:
|
include:
|
||||||
- Apps/Sybil/project.yml
|
- Apps/Sybil/project.yml
|
||||||
- Apps/SybilMacQuickQuestion/project.yml
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
|
|
||||||
LOG_DIR="$ROOT/.devbox/logs"
|
|
||||||
PID_DIR="$ROOT/.devbox/pids"
|
|
||||||
|
|
||||||
require_env() {
|
|
||||||
local name="$1"
|
|
||||||
if [[ -z "${!name:-}" ]]; then
|
|
||||||
echo "Missing required environment variable: $name" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
require_env OPENAI_API_KEY
|
|
||||||
require_env EXA_API_KEY
|
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
|
||||||
|
|
||||||
export HOST="${HOST:-0.0.0.0}"
|
|
||||||
export PORT="${PORT:-8787}"
|
|
||||||
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
|
|
||||||
|
|
||||||
echo "Installing server dependencies..."
|
|
||||||
npm ci --prefix "$ROOT/server" --no-audit --no-fund
|
|
||||||
|
|
||||||
echo "Installing web dependencies..."
|
|
||||||
npm ci --prefix "$ROOT/web" --no-audit --no-fund
|
|
||||||
|
|
||||||
if command -v tmux >/dev/null 2>&1; then
|
|
||||||
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
|
|
||||||
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
|
|
||||||
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
|
|
||||||
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
|
|
||||||
tmux set-environment -t "$SESSION" HOST "$HOST"
|
|
||||||
tmux set-environment -t "$SESSION" PORT "$PORT"
|
|
||||||
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
|
|
||||||
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
|
|
||||||
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
|
|
||||||
fi
|
|
||||||
for name in \
|
|
||||||
CHAT_CODEX_TOOL_ENABLED \
|
|
||||||
CHAT_CODEX_REMOTE_HOST \
|
|
||||||
CHAT_CODEX_REMOTE_USER \
|
|
||||||
CHAT_CODEX_REMOTE_PORT \
|
|
||||||
CHAT_CODEX_REMOTE_WORKDIR \
|
|
||||||
CHAT_CODEX_SSH_KEY_PATH \
|
|
||||||
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
|
|
||||||
CHAT_CODEX_EXEC_TIMEOUT_MS \
|
|
||||||
CHAT_SHELL_TOOL_ENABLED \
|
|
||||||
CHAT_SHELL_EXEC_TIMEOUT_MS; do
|
|
||||||
if [[ -n "${!name:-}" ]]; then
|
|
||||||
tmux set-environment -t "$SESSION" "$name" "${!name}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
|
|
||||||
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
|
|
||||||
"npm run dev -- --host 0.0.0.0 --port 5173"
|
|
||||||
echo "Started Sybil in tmux session '$SESSION'."
|
|
||||||
echo "Server: http://127.0.0.1:8787"
|
|
||||||
echo "Web: http://127.0.0.1:5173"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "tmux not found; starting background processes with nohup."
|
|
||||||
|
|
||||||
if [[ -f "$PID_DIR/server.pid" ]]; then
|
|
||||||
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if [[ -f "$PID_DIR/web.pid" ]]; then
|
|
||||||
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT/server"
|
|
||||||
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
|
|
||||||
) >"$LOG_DIR/server.log" 2>&1 &
|
|
||||||
echo "$!" >"$PID_DIR/server.pid"
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT/web"
|
|
||||||
exec npm run dev -- --host 0.0.0.0 --port 5173
|
|
||||||
) >"$LOG_DIR/web.log" 2>&1 &
|
|
||||||
echo "$!" >"$PID_DIR/web.pid"
|
|
||||||
|
|
||||||
echo "Started Sybil background processes."
|
|
||||||
echo "Server log: $LOG_DIR/server.log"
|
|
||||||
echo "Web log: $LOG_DIR/web.log"
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
REPO="${REPO:-Sybil-2}"
|
|
||||||
WORKLOAD="${WORKLOAD:-sybil-dev}"
|
|
||||||
CLUSTER="${CLUSTER:-viper}"
|
|
||||||
NAMESPACE="${NAMESPACE:-buzzert}"
|
|
||||||
QUOTA="${QUOTA:-research-api}"
|
|
||||||
SECRET="${SECRET:-sybil-dev-env}"
|
|
||||||
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
|
|
||||||
INGRESS_PORT="${INGRESS_PORT:-5173}"
|
|
||||||
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
|
|
||||||
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
|
|
||||||
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
|
|
||||||
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
|
|
||||||
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
|
|
||||||
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
|
|
||||||
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
|
|
||||||
|
|
||||||
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
|
|
||||||
brix create \
|
|
||||||
--project-root "$(dirname "$ROOT")" \
|
|
||||||
--path "$REPO=$ROOT" \
|
|
||||||
--repositories "$REPO" \
|
|
||||||
--include-untracked \
|
|
||||||
--reconcile-policy continuous \
|
|
||||||
-- \
|
|
||||||
"cluster=$CLUSTER" \
|
|
||||||
"namespace=$NAMESPACE" \
|
|
||||||
"name=$WORKLOAD" \
|
|
||||||
"pool.spec.quota=$QUOTA" \
|
|
||||||
"pool.spec.size.resource=cpu" \
|
|
||||||
"pool.spec.size.quantity=1" \
|
|
||||||
"pool.spec.priority=low"
|
|
||||||
|
|
||||||
if [[ "$REPO" != "openai" ]]; then
|
|
||||||
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
|
|
||||||
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
|
|
||||||
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
|
|
||||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
|
||||||
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
|
|
||||||
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
|
||||||
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
|
||||||
tmpdir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
|
||||||
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
|
|
||||||
--from-file=id_ed25519="$tmpdir/id_ed25519" \
|
|
||||||
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
|
|
||||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
|
||||||
fi
|
|
||||||
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
|
|
||||||
--arg host "$CODEX_SERVICE" \
|
|
||||||
--arg port "$CODEX_SSH_PORT" \
|
|
||||||
--arg workdir "$CODEX_WORKDIR" \
|
|
||||||
--arg keyPath "$CODEX_SSH_KEY_PATH" \
|
|
||||||
'{
|
|
||||||
stringData: {
|
|
||||||
CHAT_CODEX_TOOL_ENABLED: "true",
|
|
||||||
CHAT_CODEX_REMOTE_HOST: $host,
|
|
||||||
CHAT_CODEX_REMOTE_USER: "root",
|
|
||||||
CHAT_CODEX_REMOTE_PORT: $port,
|
|
||||||
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
|
|
||||||
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
|
|
||||||
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
|
|
||||||
CHAT_SHELL_TOOL_ENABLED: "true"
|
|
||||||
}
|
|
||||||
}')"
|
|
||||||
|
|
||||||
ensure_codex_deployment() {
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: $CODEX_SERVICE
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: $CODEX_SERVICE
|
|
||||||
app.kubernetes.io/part-of: sybil-dev
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: $CODEX_SERVICE
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: $CODEX_SERVICE
|
|
||||||
app.kubernetes.io/part-of: sybil-dev
|
|
||||||
spec:
|
|
||||||
tolerations:
|
|
||||||
- key: openai.com/team
|
|
||||||
operator: Exists
|
|
||||||
effect: NoSchedule
|
|
||||||
containers:
|
|
||||||
- name: $CODEX_CONTAINER
|
|
||||||
image: $CODEX_IMAGE
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
command: ["/bin/bash", "-lc"]
|
|
||||||
args:
|
|
||||||
- |
|
|
||||||
set -euo pipefail
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
if ! command -v sshd >/dev/null 2>&1; then
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
fi
|
|
||||||
if ! command -v codex >/dev/null 2>&1; then
|
|
||||||
npm install -g @openai/codex
|
|
||||||
fi
|
|
||||||
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
|
|
||||||
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
|
|
||||||
chmod 700 /root/.ssh
|
|
||||||
chmod 600 /root/.ssh/authorized_keys
|
|
||||||
{
|
|
||||||
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
|
|
||||||
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
|
|
||||||
fi
|
|
||||||
if [[ -n "\${EXA_API_KEY:-}" ]]; then
|
|
||||||
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
|
|
||||||
fi
|
|
||||||
} >/root/.sybil-codex-env
|
|
||||||
chmod 600 /root/.sybil-codex-env
|
|
||||||
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -f /root/.sybil-codex-env ]]; then
|
|
||||||
source /root/.sybil-codex-env
|
|
||||||
fi
|
|
||||||
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
|
|
||||||
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
|
|
||||||
fi
|
|
||||||
exec /bin/bash -l
|
|
||||||
SSH_COMMAND
|
|
||||||
chmod 700 /usr/local/bin/sybil-codex-ssh-command
|
|
||||||
ssh-keygen -A
|
|
||||||
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
|
|
||||||
Port $CODEX_SSH_PORT
|
|
||||||
ListenAddress 0.0.0.0
|
|
||||||
PermitRootLogin prohibit-password
|
|
||||||
PasswordAuthentication no
|
|
||||||
PubkeyAuthentication yes
|
|
||||||
AcceptEnv OPENAI_API_KEY EXA_API_KEY
|
|
||||||
AuthorizedKeysFile .ssh/authorized_keys
|
|
||||||
PermitUserEnvironment no
|
|
||||||
ForceCommand /usr/local/bin/sybil-codex-ssh-command
|
|
||||||
AllowTcpForwarding no
|
|
||||||
X11Forwarding no
|
|
||||||
Subsystem sftp internal-sftp
|
|
||||||
PidFile /run/sshd/sybil-codex.pid
|
|
||||||
SSHD_CONFIG
|
|
||||||
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
|
|
||||||
ports:
|
|
||||||
- name: ssh
|
|
||||||
containerPort: $CODEX_SSH_PORT
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: $SECRET
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "1"
|
|
||||||
memory: 4Gi
|
|
||||||
limits:
|
|
||||||
cpu: "1"
|
|
||||||
memory: 4Gi
|
|
||||||
volumeMounts:
|
|
||||||
- name: sybil-codex-ssh
|
|
||||||
mountPath: /run/secrets/sybil-codex-ssh
|
|
||||||
readOnly: true
|
|
||||||
- name: sybil-codex-workspace
|
|
||||||
mountPath: /workspace
|
|
||||||
volumes:
|
|
||||||
- name: sybil-codex-ssh
|
|
||||||
secret:
|
|
||||||
secretName: $CODEX_SSH_SECRET
|
|
||||||
defaultMode: 384
|
|
||||||
- name: sybil-codex-workspace
|
|
||||||
emptyDir: {}
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: $CODEX_SERVICE
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: $CODEX_SERVICE
|
|
||||||
app.kubernetes.io/part-of: sybil-dev
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: $CODEX_SERVICE
|
|
||||||
ports:
|
|
||||||
- name: ssh
|
|
||||||
port: $CODEX_SSH_PORT
|
|
||||||
targetPort: ssh
|
|
||||||
KUBE_MANIFEST
|
|
||||||
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_codex_deployment
|
|
||||||
|
|
||||||
ensure_env_from() {
|
|
||||||
local resource="$1"
|
|
||||||
local name="$2"
|
|
||||||
local jsonpath="$3"
|
|
||||||
local patch_path="$4"
|
|
||||||
local existing_env_from
|
|
||||||
|
|
||||||
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
|
|
||||||
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local patch
|
|
||||||
if [[ -z "$existing_env_from" ]]; then
|
|
||||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
|
|
||||||
else
|
|
||||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
|
|
||||||
fi
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_env_from \
|
|
||||||
workload \
|
|
||||||
"$WORKLOAD" \
|
|
||||||
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
|
|
||||||
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
|
|
||||||
|
|
||||||
ensure_env_from \
|
|
||||||
pool \
|
|
||||||
"$WORKLOAD" \
|
|
||||||
"{.spec.workers.template.spec.containers[0].envFrom}" \
|
|
||||||
"/spec/workers/template/spec/containers/0/envFrom"
|
|
||||||
|
|
||||||
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
|
|
||||||
name: "sybil-codex-ssh",
|
|
||||||
secret: {
|
|
||||||
secretName: $secret,
|
|
||||||
defaultMode: 384
|
|
||||||
}
|
|
||||||
}')"
|
|
||||||
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
|
|
||||||
|
|
||||||
patch_pool_codex_mount() {
|
|
||||||
local pool_json containers volumes patch
|
|
||||||
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
|
|
||||||
containers="$(jq \
|
|
||||||
--arg sidecarName "$CODEX_CONTAINER" \
|
|
||||||
--argjson mainMount "$codex_main_mount_json" \
|
|
||||||
'
|
|
||||||
def ensure_mount($mount):
|
|
||||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
|
||||||
(.spec.workers.template.spec.containers // [])
|
|
||||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
|
||||||
| map(select(.name != $sidecarName))
|
|
||||||
' <<<"$pool_json")"
|
|
||||||
volumes="$(jq \
|
|
||||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
|
||||||
'
|
|
||||||
(.spec.workers.template.spec.volumes // [])
|
|
||||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
|
||||||
+ [$sshVolume]
|
|
||||||
' <<<"$pool_json")"
|
|
||||||
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
|
|
||||||
spec: {
|
|
||||||
workers: {
|
|
||||||
template: {
|
|
||||||
spec: {
|
|
||||||
containers: $containers,
|
|
||||||
volumes: $volumes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}')"
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
patch_workload_codex_mount() {
|
|
||||||
local workload_json pools patch
|
|
||||||
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
|
|
||||||
pools="$(jq \
|
|
||||||
--arg groupName "$WORKLOAD" \
|
|
||||||
--arg sidecarName "$CODEX_CONTAINER" \
|
|
||||||
--argjson mainMount "$codex_main_mount_json" \
|
|
||||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
|
||||||
'
|
|
||||||
def ensure_mount($mount):
|
|
||||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
|
||||||
.spec.pools
|
|
||||||
| map(
|
|
||||||
if .groupName == $groupName then
|
|
||||||
.template.template.spec.workers.template.spec.containers =
|
|
||||||
((.template.template.spec.workers.template.spec.containers // [])
|
|
||||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
|
||||||
| map(select(.name != $sidecarName)))
|
|
||||||
| .template.template.spec.workers.template.spec.volumes =
|
|
||||||
((.template.template.spec.workers.template.spec.volumes // [])
|
|
||||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
|
||||||
+ [$sshVolume])
|
|
||||||
else
|
|
||||||
.
|
|
||||||
end
|
|
||||||
)
|
|
||||||
' <<<"$workload_json")"
|
|
||||||
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
|
|
||||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
patch_workload_codex_mount
|
|
||||||
patch_pool_codex_mount
|
|
||||||
|
|
||||||
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
|
|
||||||
deadline=$((SECONDS + 300))
|
|
||||||
while true; do
|
|
||||||
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
|
||||||
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
|
|
||||||
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
|
||||||
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( SECONDS >= deadline )); then
|
|
||||||
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
|
|
||||||
|
|
||||||
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
|
|
||||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
|
|
||||||
|
|
||||||
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
|
|
||||||
|
|
||||||
echo "Devbox update complete: $url"
|
|
||||||
+4
-6
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
|
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||||
- Personal chat database (chats/messages + LLM call log)
|
- Personal chat database (chats/messages + LLM call log)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -43,16 +43,14 @@ 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`
|
||||||
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
|
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||||
- `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 managed-tool providers)
|
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
|
||||||
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
|
- `CHAT_CODEX_REMOTE_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)
|
||||||
@@ -60,7 +58,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
|
- `CHAT_CODEX_SSH_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 managed-tool providers on the same devbox)
|
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Add durable root grouping and one-time fork title generation state.
|
|
||||||
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
|
||||||
@@ -13,7 +13,6 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
gemini
|
|
||||||
hermes_agent @map("hermes-agent")
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ model Chat {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
title String?
|
title String?
|
||||||
titleGenerationPending Boolean @default(false)
|
|
||||||
|
|
||||||
initiatedProvider Provider?
|
initiatedProvider Provider?
|
||||||
initiatedModel String?
|
initiatedModel String?
|
||||||
@@ -65,17 +63,11 @@ model Chat {
|
|||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
// Forks always point directly to the single root chat, never to another fork.
|
|
||||||
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
|
||||||
parentChatId String?
|
|
||||||
childChats Chat[] @relation("ChatForks")
|
|
||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
projectItems ProjectItem[]
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([parentChatId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
|
|||||||
+1
-11
@@ -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", "brave"]).default("exa")
|
z.enum(["exa", "searxng"]).default("exa")
|
||||||
);
|
);
|
||||||
|
|
||||||
const BooleanFlagSchema = z.preprocess((value) => {
|
const BooleanFlagSchema = z.preprocess((value) => {
|
||||||
@@ -66,12 +66,10 @@ 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,
|
||||||
@@ -101,14 +99,6 @@ const EnvSchema = z.object({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["BRAVE_SEARCH_API_KEY"],
|
|
||||||
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
|
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,7 +7,6 @@ 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";
|
||||||
@@ -508,33 +507,11 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
|
||||||
const response = await searchBrave(args.query, {
|
|
||||||
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
|
||||||
includeDomains: args.includeDomains,
|
|
||||||
excludeDomains: args.excludeDomains,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
searchEngine: "brave",
|
|
||||||
query: args.query,
|
|
||||||
requestId: response.requestId,
|
|
||||||
results: response.results.map((result, index) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
...result,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,501 +0,0 @@
|
|||||||
import {
|
|
||||||
buildChatToolSystemPrompt,
|
|
||||||
executeToolCallAndBuildEvent,
|
|
||||||
getEnabledChatTools,
|
|
||||||
getUnstreamedText,
|
|
||||||
looksLikeDanglingToolIntent,
|
|
||||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
|
||||||
MAX_TOOL_ROUNDS,
|
|
||||||
prepareToolCallExecution,
|
|
||||||
type NormalizedToolCall,
|
|
||||||
type ToolAwareCompletionParams,
|
|
||||||
type ToolAwareCompletionResult,
|
|
||||||
type ToolAwareStreamingEvent,
|
|
||||||
type ToolAwareUsage,
|
|
||||||
type ToolExecutionEvent,
|
|
||||||
} from "../chat-tools.js";
|
|
||||||
import {
|
|
||||||
buildImageSummaryText,
|
|
||||||
buildTextAttachmentPrompt,
|
|
||||||
buildTopLevelSystemPrompt,
|
|
||||||
getImageAttachments,
|
|
||||||
getTextAttachments,
|
|
||||||
parseImageDataUrl,
|
|
||||||
} from "../message-content.js";
|
|
||||||
import type { ChatMessage } from "../types.js";
|
|
||||||
|
|
||||||
type GeminiClient = {
|
|
||||||
apiKey: string;
|
|
||||||
baseURL: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const INTERNAL_CORRECTION =
|
|
||||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
|
||||||
|
|
||||||
function normalizeModelResourceName(model: string) {
|
|
||||||
const trimmed = model.trim().replace(/^\/+/, "");
|
|
||||||
return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record<string, string> = {}) {
|
|
||||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`);
|
|
||||||
url.searchParams.set("key", client.apiKey);
|
|
||||||
for (const [key, value] of Object.entries(extraParams)) {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationConfig(params: Pick<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
|
||||||
const config: Record<string, unknown> = {};
|
|
||||||
if (params.temperature !== undefined) config.temperature = params.temperature;
|
|
||||||
if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens;
|
|
||||||
return Object.keys(config).length ? config : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toGeminiJsonSchema(schema: unknown): Record<string, unknown> | undefined {
|
|
||||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
||||||
const input = schema as Record<string, unknown>;
|
|
||||||
const output: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (typeof input.type === "string") output.type = input.type;
|
|
||||||
if (typeof input.description === "string") output.description = input.description;
|
|
||||||
if (typeof input.format === "string") output.format = input.format;
|
|
||||||
if (typeof input.nullable === "boolean") output.nullable = input.nullable;
|
|
||||||
if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string");
|
|
||||||
if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string");
|
|
||||||
|
|
||||||
const items = toGeminiJsonSchema(input.items);
|
|
||||||
if (items) output.items = items;
|
|
||||||
|
|
||||||
if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) {
|
|
||||||
const properties: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(input.properties)) {
|
|
||||||
const propertySchema = toGeminiJsonSchema(value);
|
|
||||||
if (propertySchema) properties[key] = propertySchema;
|
|
||||||
}
|
|
||||||
if (Object.keys(properties).length) output.properties = properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(output).length ? output : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toGeminiTools(tools: any[]) {
|
|
||||||
const functionDeclarations = tools
|
|
||||||
.map((tool) => {
|
|
||||||
if (tool?.type !== "function") return null;
|
|
||||||
const declaration: Record<string, unknown> = {
|
|
||||||
name: tool.function.name,
|
|
||||||
description: tool.function.description,
|
|
||||||
};
|
|
||||||
const parameters = toGeminiJsonSchema(tool.function.parameters);
|
|
||||||
if (parameters) declaration.parameters = parameters;
|
|
||||||
return declaration;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return functionDeclarations.length ? [{ functionDeclarations }] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toContentParts(message: ChatMessage) {
|
|
||||||
const imageAttachments = getImageAttachments(message);
|
|
||||||
const textAttachments = getTextAttachments(message);
|
|
||||||
const parts: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (const attachment of imageAttachments) {
|
|
||||||
const source = parseImageDataUrl(attachment);
|
|
||||||
parts.push({
|
|
||||||
inlineData: {
|
|
||||||
mimeType: source.mediaType,
|
|
||||||
data: source.data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
|
||||||
if (imageSummary) {
|
|
||||||
parts.push({ text: imageSummary });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attachment of textAttachments) {
|
|
||||||
parts.push({ text: buildTextAttachmentPrompt(attachment) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.content.trim()) {
|
|
||||||
parts.push({ text: message.content });
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length ? parts : [{ text: "" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConversationContent(message: ChatMessage) {
|
|
||||||
if (message.role === "system") {
|
|
||||||
throw new Error("System messages must be handled separately for Gemini.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.role === "tool") {
|
|
||||||
const name = message.name?.trim() || "tool";
|
|
||||||
return {
|
|
||||||
role: "user",
|
|
||||||
parts: [{ text: `Tool output (${name}):\n${message.content}` }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: message.role === "assistant" ? "model" : "user",
|
|
||||||
parts: toContentParts(message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBaseContents(messages: ChatMessage[]) {
|
|
||||||
return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) {
|
|
||||||
const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt);
|
|
||||||
return text ? { parts: [{ text }] } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
|
||||||
const normalized = normalizeUsage(usage);
|
|
||||||
if (!normalized) return false;
|
|
||||||
acc.inputTokens += normalized.inputTokens;
|
|
||||||
acc.outputTokens += normalized.outputTokens;
|
|
||||||
acc.totalTokens += normalized.totalTokens;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUsage(usage: any) {
|
|
||||||
if (!usage) return null;
|
|
||||||
const inputTokens = usage.promptTokenCount ?? 0;
|
|
||||||
const outputTokens = usage.candidatesTokenCount ?? 0;
|
|
||||||
const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens;
|
|
||||||
return { inputTokens, outputTokens, totalTokens };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCandidate(response: any) {
|
|
||||||
return Array.isArray(response?.candidates) ? response.candidates[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParts(response: any) {
|
|
||||||
const parts = getCandidate(response)?.content?.parts;
|
|
||||||
return Array.isArray(parts) ? parts : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractText(response: any) {
|
|
||||||
return getParts(response)
|
|
||||||
.map((part: any) => (typeof part?.text === "string" ? part.text : ""))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyToolArgs(args: unknown) {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(args ?? {});
|
|
||||||
} catch {
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] {
|
|
||||||
return parts
|
|
||||||
.filter((part) => part?.functionCall)
|
|
||||||
.map((part, index) => ({
|
|
||||||
id: part.functionCall.id ?? `tool_call_${round}_${index}`,
|
|
||||||
name: part.functionCall.name ?? "unknown_tool",
|
|
||||||
arguments: stringifyToolArgs(part.functionCall.args),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) {
|
|
||||||
return {
|
|
||||||
functionResponse: {
|
|
||||||
id: call.id,
|
|
||||||
name: call.name,
|
|
||||||
response: toolResult,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCorrection(conversation: any[], text: string) {
|
|
||||||
conversation.push({ role: "model", parts: [{ text }] });
|
|
||||||
conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseGeminiResponse(response: Response) {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
let body: any = null;
|
|
||||||
try {
|
|
||||||
body = bodyText ? JSON.parse(bodyText) : null;
|
|
||||||
} catch {
|
|
||||||
body = { raw: bodyText };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateContent(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
|
||||||
const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return parseGeminiResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFailureMessage(response: any, text: string, toolCallCount: number) {
|
|
||||||
const promptBlockReason = response?.promptFeedback?.blockReason;
|
|
||||||
if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`;
|
|
||||||
|
|
||||||
const candidate = getCandidate(response);
|
|
||||||
const finishReason = candidate?.finishReason;
|
|
||||||
if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null;
|
|
||||||
if (text || toolCallCount > 0) return null;
|
|
||||||
return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) {
|
|
||||||
const tools = toGeminiTools(enabledTools);
|
|
||||||
return {
|
|
||||||
contents: conversation,
|
|
||||||
systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined),
|
|
||||||
generationConfig: generationConfig(params),
|
|
||||||
tools,
|
|
||||||
toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const conversation = buildBaseContents(params.messages);
|
|
||||||
const rawResponses: unknown[] = [];
|
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
let sawUsage = false;
|
|
||||||
let totalToolCalls = 0;
|
|
||||||
let danglingToolIntentRetries = 0;
|
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
||||||
const response = await generateContent(params, buildRequest(params, conversation, enabledTools));
|
|
||||||
rawResponses.push(response);
|
|
||||||
sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage;
|
|
||||||
|
|
||||||
const parts = getParts(response);
|
|
||||||
const text = extractText(response);
|
|
||||||
const normalizedToolCalls = normalizeToolCallsFromParts(parts, round);
|
|
||||||
const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length);
|
|
||||||
if (failureMessage) throw new Error(failureMessage);
|
|
||||||
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendCorrection(conversation, text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
conversation.push({ role: "model", parts });
|
|
||||||
|
|
||||||
const toolResultParts: any[] = [];
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { execution } = prepareToolCallExecution(call);
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
conversation.push({ role: "user", parts: toolResultParts });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSseBoundary(buffer: string) {
|
|
||||||
const crlf = buffer.indexOf("\r\n\r\n");
|
|
||||||
const lf = buffer.indexOf("\n\n");
|
|
||||||
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
|
||||||
if (lf === -1) return { index: crlf, length: 4 };
|
|
||||||
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSseEvent(rawEvent: string) {
|
|
||||||
const data = rawEvent
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.filter((line) => line.startsWith("data:"))
|
|
||||||
.map((line) => line.slice("data:".length).trimStart())
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
|
||||||
if (!data || data === "[DONE]") return null;
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record<string, unknown>) {
|
|
||||||
const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
await parseGeminiResponse(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("Gemini stream response did not include a body.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let boundary = findSseBoundary(buffer);
|
|
||||||
while (boundary) {
|
|
||||||
const rawEvent = buffer.slice(0, boundary.index);
|
|
||||||
buffer = buffer.slice(boundary.index + boundary.length);
|
|
||||||
const event = parseSseEvent(rawEvent);
|
|
||||||
if (event) yield event;
|
|
||||||
boundary = findSseBoundary(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
const tail = buffer.trim();
|
|
||||||
if (tail) {
|
|
||||||
const event = parseSseEvent(tail);
|
|
||||||
if (event) yield event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const conversation = buildBaseContents(params.messages);
|
|
||||||
const rawResponses: unknown[] = [];
|
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
let sawUsage = false;
|
|
||||||
let totalToolCalls = 0;
|
|
||||||
let danglingToolIntentRetries = 0;
|
|
||||||
|
|
||||||
if (!enabledTools.length) {
|
|
||||||
let text = "";
|
|
||||||
let latestUsage: any = null;
|
|
||||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) {
|
|
||||||
rawResponses.push(response);
|
|
||||||
if (response?.usageMetadata) latestUsage = response.usageMetadata;
|
|
||||||
const failureMessage = getFailureMessage(response, extractText(response), 0);
|
|
||||||
if (failureMessage) throw new Error(failureMessage);
|
|
||||||
const delta = extractText(response);
|
|
||||||
if (delta) {
|
|
||||||
text += delta;
|
|
||||||
yield { type: "delta", text: delta };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage;
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" },
|
|
||||||
toolEvents: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
||||||
const roundParts: any[] = [];
|
|
||||||
let roundText = "";
|
|
||||||
let latestRoundResponse: any = null;
|
|
||||||
let latestRoundUsage: any = null;
|
|
||||||
|
|
||||||
for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) {
|
|
||||||
rawResponses.push(response);
|
|
||||||
latestRoundResponse = response;
|
|
||||||
if (response?.usageMetadata) latestRoundUsage = response.usageMetadata;
|
|
||||||
roundParts.push(...getParts(response));
|
|
||||||
roundText += extractText(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage;
|
|
||||||
|
|
||||||
const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round);
|
|
||||||
const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length);
|
|
||||||
if (failureMessage) throw new Error(failureMessage);
|
|
||||||
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendCorrection(conversation, roundText);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const unstreamedText = getUnstreamedText(roundText, "");
|
|
||||||
if (unstreamedText) {
|
|
||||||
yield { type: "delta", text: unstreamedText };
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text: roundText,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
conversation.push({ role: "model", parts: roundParts });
|
|
||||||
|
|
||||||
const toolResultParts: any[] = [];
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
|
||||||
yield { type: "tool_call", event: initiatedEvent };
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
yield { type: "tool_call", event };
|
|
||||||
toolResultParts.push(buildFunctionResponsePart(call, toolResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
conversation.push({ role: "user", parts: toolResultParts });
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: {
|
|
||||||
streamed: true,
|
|
||||||
responses: rawResponses,
|
|
||||||
toolCallsUsed: totalToolCalls,
|
|
||||||
toolCallLimitReached: true,
|
|
||||||
api: "gemini.streamGenerateContent",
|
|
||||||
},
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -28,45 +28,6 @@ import type { ChatMessage } from "../types.js";
|
|||||||
const INTERNAL_CORRECTION =
|
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) => {
|
||||||
@@ -199,12 +160,11 @@ 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: maxTokens,
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
} as any);
|
} as any);
|
||||||
@@ -232,7 +192,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: maxTokens,
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
@@ -288,7 +248,6 @@ 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 };
|
||||||
@@ -300,7 +259,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: maxTokens,
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: buildBaseMessages(params),
|
messages: buildBaseMessages(params),
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -356,7 +315,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: maxTokens,
|
max_tokens: params.maxTokens ?? 1024,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
messages: conversation,
|
messages: conversation,
|
||||||
tools: toTools(enabledTools),
|
tools: toTools(enabledTools),
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ 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, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, 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 = {
|
||||||
@@ -28,7 +27,7 @@ export type ProviderChatAdapter = {
|
|||||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||||
|
|
||||||
type ChatProtocol = {
|
type ChatProtocol = {
|
||||||
id: ChatProtocolId;
|
id: ChatProtocolId;
|
||||||
@@ -40,7 +39,6 @@ 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 = {
|
||||||
@@ -63,12 +61,6 @@ 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,
|
||||||
@@ -85,10 +77,6 @@ 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;
|
||||||
@@ -98,37 +86,6 @@ 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,
|
||||||
@@ -203,29 +160,6 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
gemini: {
|
|
||||||
createClient: geminiClient,
|
|
||||||
plainProtocol: geminiProtocol,
|
|
||||||
toolProtocol: geminiProtocol,
|
|
||||||
managedTools: true,
|
|
||||||
modelCatalog: {
|
|
||||||
async fetchModels(client) {
|
|
||||||
const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`);
|
|
||||||
url.searchParams.set("key", client.apiKey);
|
|
||||||
url.searchParams.set("pageSize", "1000");
|
|
||||||
const page = await fetchJson(url);
|
|
||||||
return Array.isArray(page?.models)
|
|
||||||
? page.models
|
|
||||||
.filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent"))
|
|
||||||
.map((model: any) => model?.name)
|
|
||||||
.filter((id: unknown): id is string => typeof id === "string")
|
|
||||||
.map(stripModelResourcePrefix)
|
|
||||||
.filter(isLikelyGeminiChatModel)
|
|
||||||
: [];
|
|
||||||
},
|
|
||||||
sortModels: preferGeminiModels,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"hermes-agent": {
|
"hermes-agent": {
|
||||||
createClient: hermesAgentClient,
|
createClient: hermesAgentClient,
|
||||||
plainProtocol: chatCompletionsProtocol,
|
plainProtocol: chatCompletionsProtocol,
|
||||||
@@ -275,8 +209,7 @@ 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 [];
|
||||||
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
return 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,7 +6,6 @@ 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>;
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ 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 Anthropic from "@anthropic-ai/sdk";
|
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
|
||||||
export function openaiClient() {
|
export function openaiClient() {
|
||||||
@@ -13,14 +13,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
|
||||||
|
|
||||||
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
|
||||||
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
|
||||||
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
|
||||||
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
|
||||||
|
|
||||||
export type QuickQuestionRequest = {
|
|
||||||
provider: Provider;
|
|
||||||
model: string;
|
|
||||||
question: string;
|
|
||||||
enabledTools?: string[];
|
|
||||||
userLocation?: string;
|
|
||||||
temperature?: number;
|
|
||||||
maxTokens?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildQuickQuestionMultiplexRequest({
|
|
||||||
question,
|
|
||||||
...request
|
|
||||||
}: QuickQuestionRequest): MultiplexRequest {
|
|
||||||
return {
|
|
||||||
...request,
|
|
||||||
persist: false,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
|
||||||
{ role: "user", content: question },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -119,12 +119,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
if (shouldPersist && chatId && call) {
|
if (shouldPersist && chatId && call) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.message.create({
|
await tx.message.create({
|
||||||
data: {
|
data: { chatId, role: "assistant" as any, content: text },
|
||||||
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", "gemini", "hermes-agent"] as const;
|
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||||
|
|
||||||
export type Provider = (typeof PROVIDERS)[number];
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ 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[];
|
||||||
|
|||||||
+37
-322
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
@@ -11,16 +10,14 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
|||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
|
||||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
@@ -91,13 +88,13 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
|||||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
|
|
||||||
const existing = await prisma.message.findMany({
|
const existing = await prisma.message.findMany({
|
||||||
where: { chatId },
|
where: { chatId },
|
||||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
orderBy: { createdAt: "asc" },
|
||||||
select: { role: true, content: true, name: true, metadata: true },
|
select: { role: true, content: true, name: true, metadata: true },
|
||||||
});
|
});
|
||||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||||
@@ -112,21 +109,14 @@ 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, index) => {
|
data: toInsert.map((m) => ({
|
||||||
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: Object.keys(metadata).length ? (metadata as any) : undefined,
|
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +169,6 @@ 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),
|
||||||
@@ -197,24 +186,7 @@ const CompletionStreamBody = z
|
|||||||
path: ["chatId"],
|
path: ["chatId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "clientRequestId requires a persisted stream with chatId",
|
|
||||||
path: ["clientRequestId"],
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuickQuestionStreamBody = z.object({
|
|
||||||
provider: ProviderSchema,
|
|
||||||
model: z.string().min(1),
|
|
||||||
question: z.string().trim().min(1),
|
|
||||||
enabledTools: EnabledToolsSchema.optional(),
|
|
||||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
|
||||||
maxTokens: z.number().int().positive().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
if (!attachments?.length) return metadata as any;
|
if (!attachments?.length) return metadata as any;
|
||||||
@@ -330,21 +302,6 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
|||||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
|
|
||||||
if (messageContent !== undefined) {
|
|
||||||
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
|
|
||||||
return `Fork of '${snippet}'`;
|
|
||||||
}
|
|
||||||
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function copyForkMessageMetadata(metadata: unknown) {
|
|
||||||
if (metadata === null || metadata === undefined) return undefined;
|
|
||||||
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
|
||||||
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
|
|
||||||
return Object.keys(copied).length ? copied : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateChatTitle(content: string) {
|
async function generateChatTitle(content: string) {
|
||||||
const systemPrompt =
|
const systemPrompt =
|
||||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||||
@@ -442,8 +399,6 @@ function buildSseHeaders(originHeader: string | undefined) {
|
|||||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
const activeChatStreamRequestIds = new Map<string, string>();
|
|
||||||
const chatDeletionRoots = new Set<string>();
|
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
const STARRED_PROJECT_ID = "starred";
|
const STARRED_PROJECT_ID = "starred";
|
||||||
|
|
||||||
@@ -456,8 +411,6 @@ const starredProjectItemsSelect = {
|
|||||||
const chatSummarySelect = {
|
const chatSummarySelect = {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
titleGenerationPending: true,
|
|
||||||
parentChatId: true,
|
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
initiatedProvider: true,
|
initiatedProvider: true,
|
||||||
@@ -538,29 +491,6 @@ async function getSearchSummary(searchId: string) {
|
|||||||
return search ? serializeSearchLike(search) : null;
|
return search ? serializeSearchLike(search) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listRecentChatsWithRoots() {
|
|
||||||
const chats = await prisma.chat.findMany({
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
const includedIds = new Set(chats.map((chat) => chat.id));
|
|
||||||
const missingRootIds = [
|
|
||||||
...new Set(
|
|
||||||
chats
|
|
||||||
.map((chat) => chat.parentChatId)
|
|
||||||
.filter((id): id is string => id !== null && !includedIds.has(id))
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (!missingRootIds.length) return chats;
|
|
||||||
|
|
||||||
const roots = await prisma.chat.findMany({
|
|
||||||
where: { id: { in: missingRootIds } },
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
return [...chats, ...roots].sort(compareUpdatedAtDesc);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setChatStarred(chatId: string, starred: boolean) {
|
async function setChatStarred(chatId: string, starred: boolean) {
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||||
if (!exists) return null;
|
if (!exists) return null;
|
||||||
@@ -599,7 +529,11 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
|||||||
|
|
||||||
async function listWorkspaceItems() {
|
async function listWorkspaceItems() {
|
||||||
const [chats, searches] = await Promise.all([
|
const [chats, searches] = await Promise.all([
|
||||||
listRecentChatsWithRoots(),
|
prisma.chat.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: chatSummarySelect,
|
||||||
|
}),
|
||||||
prisma.search.findMany({
|
prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -620,7 +554,6 @@ 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?.();
|
||||||
|
|
||||||
@@ -655,24 +588,10 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
|||||||
return { event: ev.type, data: ev };
|
return { event: ev.type, data: ev };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||||
const stream = new ActiveSseStream();
|
const stream = new ActiveSseStream();
|
||||||
activeChatStreams.set(chatId, stream);
|
activeChatStreams.set(chatId, stream);
|
||||||
if (clientRequestId) {
|
|
||||||
activeChatStreamRequestIds.set(chatId, clientRequestId);
|
|
||||||
} else {
|
|
||||||
activeChatStreamRequestIds.delete(chatId);
|
|
||||||
}
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
|
||||||
if (activeChatStreams.get(chatId) !== stream) return;
|
|
||||||
activeChatStreams.delete(chatId);
|
|
||||||
activeChatStreamRequestIds.delete(chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let sawTerminalEvent = false;
|
let sawTerminalEvent = false;
|
||||||
try {
|
try {
|
||||||
@@ -692,46 +611,11 @@ function executeActiveChatStream(chatId: string, body: z.infer<typeof Completion
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
} finally {
|
} finally {
|
||||||
clearActiveChatStream(chatId, stream);
|
activeChatStreams.delete(chatId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
|
||||||
|
|
||||||
function getMetadataClientRequestId(metadata: unknown) {
|
return stream;
|
||||||
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) {
|
||||||
@@ -932,7 +816,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await listRecentChatsWithRoots();
|
const chats = await prisma.chat.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: chatSummarySelect,
|
||||||
|
});
|
||||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -991,80 +879,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { chat: serializeChatLike(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/fork", async (req) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
const Params = z.object({ chatId: z.string() });
|
|
||||||
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
|
|
||||||
const { chatId } = Params.parse(req.params);
|
|
||||||
const parsed = Body.safeParse(req.body ?? {});
|
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
const source = await tx.chat.findUnique({
|
|
||||||
where: { id: chatId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
parentChatId: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
userId: true,
|
|
||||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!source) return { status: "chat-not-found" as const };
|
|
||||||
|
|
||||||
let messages = source.messages;
|
|
||||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
|
||||||
if (parsed.data.messageId) {
|
|
||||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
|
||||||
if (selectedIndex < 0) return { status: "message-not-found" as const };
|
|
||||||
selectedMessage = messages[selectedIndex];
|
|
||||||
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
|
|
||||||
messages = messages.slice(0, selectedIndex + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const forkMessageIdPrefix = `fork-${randomUUID()}`;
|
|
||||||
const chat = await tx.chat.create({
|
|
||||||
data: {
|
|
||||||
title: buildForkTitle(source.title, selectedMessage?.content),
|
|
||||||
titleGenerationPending: true,
|
|
||||||
parentChatId: source.parentChatId ?? source.id,
|
|
||||||
initiatedProvider: source.initiatedProvider,
|
|
||||||
initiatedModel: source.initiatedModel,
|
|
||||||
lastUsedProvider: source.lastUsedProvider,
|
|
||||||
lastUsedModel: source.lastUsedModel,
|
|
||||||
additionalSystemPrompt: source.additionalSystemPrompt,
|
|
||||||
enabledTools: (source.enabledTools ?? undefined) as any,
|
|
||||||
userId: source.userId,
|
|
||||||
messages: messages.length
|
|
||||||
? {
|
|
||||||
create: messages.map((message, index) => ({
|
|
||||||
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
|
|
||||||
createdAt: message.createdAt,
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
name: message.name,
|
|
||||||
metadata: copyForkMessageMetadata(message.metadata) as any,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
return { status: "created" as const, chat };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
|
|
||||||
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
|
|
||||||
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
|
|
||||||
return { chat: serializeChatLike(result.chat) };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
@@ -1077,10 +891,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.title !== undefined) {
|
if (body.title !== undefined) data.title = body.title;
|
||||||
data.title = body.title;
|
|
||||||
data.titleGenerationPending = false;
|
|
||||||
}
|
|
||||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||||
|
|
||||||
@@ -1121,30 +932,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
select: chatSummarySelect,
|
select: chatSummarySelect,
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
let suggestedRaw = "";
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
try {
|
|
||||||
suggestedRaw = await generateChatTitle(body.content);
|
|
||||||
} catch (err) {
|
|
||||||
req.log.warn(
|
|
||||||
{
|
|
||||||
chatId: body.chatId,
|
|
||||||
err: getErrorMessage(err),
|
|
||||||
},
|
|
||||||
"chat title generation failed; using fallback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
await prisma.chat.updateMany({
|
||||||
where: {
|
where: { id: body.chatId, title: existing.title },
|
||||||
id: body.chatId,
|
data: { title },
|
||||||
title: existing.title,
|
|
||||||
titleGenerationPending: existing.titleGenerationPending,
|
|
||||||
},
|
|
||||||
data: { title, titleGenerationPending: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = await getChatSummary(body.chatId);
|
const chat = await getChatSummary(body.chatId);
|
||||||
@@ -1160,46 +956,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
req.log.info({ chatId }, "delete chat requested");
|
req.log.info({ chatId }, "delete chat requested");
|
||||||
|
|
||||||
const target = await prisma.chat.findUnique({
|
|
||||||
where: { id: chatId },
|
|
||||||
select: { parentChatId: true },
|
|
||||||
});
|
|
||||||
if (!target) {
|
|
||||||
req.log.warn({ chatId }, "delete chat target not found");
|
|
||||||
return app.httpErrors.notFound("chat not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const familyRootId = target.parentChatId ?? chatId;
|
|
||||||
if (chatDeletionRoots.has(familyRootId)) {
|
|
||||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
chatDeletionRoots.add(familyRootId);
|
|
||||||
try {
|
|
||||||
const familyIds = target.parentChatId
|
|
||||||
? [chatId]
|
|
||||||
: (
|
|
||||||
await prisma.chat.findMany({
|
|
||||||
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
).map((chat) => chat.id);
|
|
||||||
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
|
||||||
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
|
||||||
return app.httpErrors.conflict("chat or fork has an active stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||||
if (result.count === 0) {
|
if (result.count === 0) {
|
||||||
req.log.warn({ chatId }, "delete chat target no longer exists");
|
req.log.warn({ chatId }, "delete chat target not found");
|
||||||
return app.httpErrors.notFound("chat not found");
|
return app.httpErrors.notFound("chat not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
req.log.info({ chatId }, "chat deleted");
|
req.log.info({ chatId }, "chat deleted");
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
} finally {
|
|
||||||
chatDeletionRoots.delete(familyRootId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/searches", async (req) => {
|
app.get("/v1/searches", async (req) => {
|
||||||
@@ -1489,7 +1253,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
include: {
|
include: {
|
||||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
messages: { orderBy: { createdAt: "asc" } },
|
||||||
calls: { orderBy: { createdAt: "desc" } },
|
calls: { orderBy: { createdAt: "desc" } },
|
||||||
projectItems: starredProjectItemsSelect,
|
projectItems: starredProjectItemsSelect,
|
||||||
},
|
},
|
||||||
@@ -1575,28 +1339,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-shot, non-persistent Quick Question SSE endpoint.
|
// Streaming SSE endpoint.
|
||||||
app.post("/v1/quick-questions/stream", async (req, reply) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
|
|
||||||
const parsed = QuickQuestionStreamBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
|
||||||
reply.raw.flushHeaders();
|
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
|
|
||||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
|
||||||
reply.raw.end();
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
});
|
|
||||||
|
|
||||||
// General chat completion SSE endpoint.
|
|
||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
@@ -1604,51 +1347,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// Ensure the chat exists and identify its family before reserving a stream.
|
// ensure chat exists if provided
|
||||||
let chatFamilyRootId: string | null = null;
|
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
const exists = await prisma.chat.findUnique({
|
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
||||||
where: { id: body.chatId },
|
|
||||||
select: { id: true, parentChatId: true },
|
|
||||||
});
|
|
||||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||||
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
}
|
||||||
|
|
||||||
|
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||||
|
if (body.persist !== false && body.chatId) {
|
||||||
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.persist !== false && body.chatId) {
|
if (body.persist !== false && body.chatId) {
|
||||||
const activeStream = activeChatStreams.get(body.chatId);
|
if (activeChatStreams.has(body.chatId)) {
|
||||||
if (activeStream) {
|
|
||||||
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
|
|
||||||
return streamActiveRun(req, reply, activeStream);
|
|
||||||
}
|
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
|
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||||
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
return streamActiveRun(req, reply, stream);
|
||||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
|
||||||
try {
|
|
||||||
if (body.clientRequestId) {
|
|
||||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
|
||||||
if (completedSubmission) {
|
|
||||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
|
||||||
clearActiveChatStream(body.chatId, reservedStream);
|
|
||||||
return streamActiveRun(req, reply, reservedStream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
|
||||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
|
||||||
const configuredBody = await applyStoredChatSettings(body);
|
|
||||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
|
||||||
return streamActiveRun(req, reply, reservedStream);
|
|
||||||
} catch (err) {
|
|
||||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
|
||||||
clearActiveChatStream(body.chatId, reservedStream);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
|
||||||
import { env } from "../env.js";
|
|
||||||
|
|
||||||
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
|
||||||
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
|
|
||||||
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
|
|
||||||
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
|
|
||||||
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
|
|
||||||
const RATE_LIMIT_RESET_SAFETY_MS = 50;
|
|
||||||
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
||||||
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
|
|
||||||
|
|
||||||
type RateLimitPolicy = {
|
|
||||||
limit: number;
|
|
||||||
windowSeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
|
||||||
let lastRequestAtMs = 0;
|
|
||||||
let nextRequestAtMs = 0;
|
|
||||||
let quotaUnavailableUntilMs = 0;
|
|
||||||
let requestQueue = Promise.resolve();
|
|
||||||
|
|
||||||
export type BraveSearchOptions = {
|
|
||||||
numResults: number;
|
|
||||||
includeDomains?: string[];
|
|
||||||
excludeDomains?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BraveSearchResult = {
|
|
||||||
title: string | null;
|
|
||||||
url: string | null;
|
|
||||||
publishedDate: string | null;
|
|
||||||
author: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
text: string | null;
|
|
||||||
highlights: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BraveSearchResponse = {
|
|
||||||
query: string;
|
|
||||||
requestId: string | null;
|
|
||||||
results: BraveSearchResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function clipText(input: string, maxCharacters: number) {
|
|
||||||
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactWhitespace(input: string) {
|
|
||||||
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireBraveSearchApiKey() {
|
|
||||||
if (!env.BRAVE_SEARCH_API_KEY) {
|
|
||||||
throw new Error("BRAVE_SEARCH_API_KEY not set");
|
|
||||||
}
|
|
||||||
return env.BRAVE_SEARCH_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(milliseconds: number) {
|
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addIntervalSafety(intervalMs: number) {
|
|
||||||
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommaSeparatedNumbers(value: string | null) {
|
|
||||||
if (!value) return [];
|
|
||||||
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
|
|
||||||
if (!value) return [];
|
|
||||||
return value.split(",").flatMap((part) => {
|
|
||||||
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
|
|
||||||
if (!match) return [];
|
|
||||||
const limit = Number(match[1]);
|
|
||||||
const windowSeconds = Number(match[2]);
|
|
||||||
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
|
|
||||||
if (!policies.length) return null;
|
|
||||||
let burstIndex = 0;
|
|
||||||
for (let index = 1; index < policies.length; index += 1) {
|
|
||||||
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
|
|
||||||
}
|
|
||||||
return burstIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRateLimitState(headers: Headers) {
|
|
||||||
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
|
|
||||||
const burstIndex = getBurstPolicyIndex(policies);
|
|
||||||
if (burstIndex === null) return;
|
|
||||||
|
|
||||||
const burstPolicy = policies[burstIndex]!;
|
|
||||||
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
|
|
||||||
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
|
|
||||||
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
|
|
||||||
}
|
|
||||||
requestIntervalMs = learnedIntervalMs;
|
|
||||||
|
|
||||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
|
||||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
|
||||||
for (let index = 0; index < policies.length; index += 1) {
|
|
||||||
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
|
|
||||||
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
|
|
||||||
if (index === burstIndex) {
|
|
||||||
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
|
|
||||||
} else {
|
|
||||||
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLongTermQuotaAvailable() {
|
|
||||||
if (quotaUnavailableUntilMs <= Date.now()) {
|
|
||||||
quotaUnavailableUntilMs = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
|
|
||||||
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForRateLimitSlot() {
|
|
||||||
const reservation = requestQueue.then(async () => {
|
|
||||||
while (true) {
|
|
||||||
assertLongTermQuotaAvailable();
|
|
||||||
const waitMs = nextRequestAtMs - Date.now();
|
|
||||||
if (waitMs <= 0) break;
|
|
||||||
await sleep(waitMs);
|
|
||||||
}
|
|
||||||
lastRequestAtMs = Date.now();
|
|
||||||
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
|
|
||||||
});
|
|
||||||
requestQueue = reservation.catch(() => undefined);
|
|
||||||
await reservation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
|
|
||||||
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
|
|
||||||
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
|
|
||||||
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
|
|
||||||
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
|
|
||||||
const exponentialDelayMs = 2 ** retryNumber * 1_000;
|
|
||||||
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
|
|
||||||
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBrave(url: URL) {
|
|
||||||
const apiKey = requireBraveSearchApiKey();
|
|
||||||
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
|
|
||||||
await waitForRateLimitSlot();
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
...buildBrowserLikeRequestHeaders("application/json"),
|
|
||||||
"X-Subscription-Token": apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRateLimitState(response.headers);
|
|
||||||
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
|
|
||||||
|
|
||||||
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
|
|
||||||
await response.arrayBuffer();
|
|
||||||
if (retryDelayMs === null) {
|
|
||||||
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
|
|
||||||
}
|
|
||||||
await sleep(retryDelayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Brave Search API request failed after rate-limit retries.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDomain(input: string) {
|
|
||||||
const trimmed = input.trim().toLowerCase();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
||||||
return parsed.hostname.replace(/^www\./, "");
|
|
||||||
} catch {
|
|
||||||
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDomains(input: string[] | undefined) {
|
|
||||||
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
|
|
||||||
if (!urlRaw) return false;
|
|
||||||
try {
|
|
||||||
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
|
|
||||||
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
|
|
||||||
const includeDomains = normalizeDomains(options.includeDomains);
|
|
||||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
|
||||||
return results.filter((result) => {
|
|
||||||
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
|
||||||
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBraveQuery(query: string, options: BraveSearchOptions) {
|
|
||||||
const includeDomains = normalizeDomains(options.includeDomains);
|
|
||||||
const excludeDomains = normalizeDomains(options.excludeDomains);
|
|
||||||
const includeClause =
|
|
||||||
includeDomains.length === 0
|
|
||||||
? ""
|
|
||||||
: includeDomains.length === 1
|
|
||||||
? `site:${includeDomains[0]}`
|
|
||||||
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
|
||||||
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
|
||||||
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchUrl(query: string, options: BraveSearchOptions) {
|
|
||||||
const url = new URL(BRAVE_WEB_SEARCH_URL);
|
|
||||||
url.searchParams.set("q", buildBraveQuery(query, options));
|
|
||||||
url.searchParams.set("count", String(options.numResults));
|
|
||||||
url.searchParams.set("safesearch", "moderate");
|
|
||||||
url.searchParams.set("result_filter", "web");
|
|
||||||
url.searchParams.set("text_decorations", "false");
|
|
||||||
url.searchParams.set("extra_snippets", "true");
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrNull(value: unknown) {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const normalized = compactWhitespace(value);
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringArray(value: unknown) {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapWebResult(result: any): BraveSearchResult {
|
|
||||||
const description = stringOrNull(result?.description);
|
|
||||||
const extraSnippets = stringArray(result?.extra_snippets);
|
|
||||||
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
|
|
||||||
const combinedText = snippets.join("\n\n");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: stringOrNull(result?.title),
|
|
||||||
url: stringOrNull(result?.url),
|
|
||||||
publishedDate: stringOrNull(result?.page_age),
|
|
||||||
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
|
|
||||||
summary: description ? clipText(description, 1_400) : null,
|
|
||||||
text: combinedText ? clipText(combinedText, 700) : null,
|
|
||||||
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
|
|
||||||
const url = buildSearchUrl(query, options);
|
|
||||||
const response = await fetchBrave(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
await response.arrayBuffer();
|
|
||||||
throw new Error(`Brave Search API request failed with status ${response.status}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
||||||
if (!contentType.includes("application/json")) {
|
|
||||||
await response.arrayBuffer();
|
|
||||||
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: any = await response.json();
|
|
||||||
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
|
|
||||||
return {
|
|
||||||
query,
|
|
||||||
requestId: response.headers.get("x-request-id"),
|
|
||||||
results: filterResultsByDomains(results, options).slice(0, options.numResults),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetBraveRateLimitStateForTests() {
|
|
||||||
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
|
|
||||||
lastRequestAtMs = 0;
|
|
||||||
nextRequestAtMs = 0;
|
|
||||||
quotaUnavailableUntilMs = 0;
|
|
||||||
requestQueue = Promise.resolve();
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { env } from "../src/env.js";
|
|
||||||
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
|
|
||||||
|
|
||||||
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
fetchCalls.push({ input, init });
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
web: {
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
title: " Brave result ",
|
|
||||||
url: "https://docs.example.com/article",
|
|
||||||
description: "Main\n snippet",
|
|
||||||
extra_snippets: ["Extra snippet one", "Extra snippet two"],
|
|
||||||
page_age: "2026-07-18T12:00:00Z",
|
|
||||||
profile: { name: "Example Docs" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Excluded result",
|
|
||||||
url: "https://blocked.example.com/article",
|
|
||||||
description: "Should be filtered",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json; charset=utf-8",
|
|
||||||
"x-request-id": "brave-request-1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await searchBrave("latest docs", {
|
|
||||||
numResults: 5,
|
|
||||||
includeDomains: ["https://example.com/path"],
|
|
||||||
excludeDomains: ["blocked.example.com"],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(fetchCalls.length, 1);
|
|
||||||
const requestUrl = new URL(String(fetchCalls[0]?.input));
|
|
||||||
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
|
|
||||||
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
|
|
||||||
assert.equal(requestUrl.searchParams.get("count"), "5");
|
|
||||||
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
|
|
||||||
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
|
|
||||||
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
|
|
||||||
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
|
|
||||||
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
|
|
||||||
|
|
||||||
assert.deepEqual(response, {
|
|
||||||
query: "latest docs",
|
|
||||||
requestId: "brave-request-1",
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
title: "Brave result",
|
|
||||||
url: "https://docs.example.com/article",
|
|
||||||
publishedDate: "2026-07-18T12:00:00Z",
|
|
||||||
author: "Example Docs",
|
|
||||||
summary: "Main snippet",
|
|
||||||
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
|
|
||||||
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave rejects requests without an API key", async () => {
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = undefined;
|
|
||||||
try {
|
|
||||||
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
|
|
||||||
} finally {
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave reports non-JSON responses", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
new Response("upstream error", {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "text/plain" },
|
|
||||||
})) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
() => searchBrave("test", { numResults: 1 }),
|
|
||||||
/Brave Search API returned text\/plain/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
const requestStartedAt: number[] = [];
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
requestStartedAt.push(Date.now());
|
|
||||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
|
||||||
"x-ratelimit-remaining": "1, 1999",
|
|
||||||
"x-ratelimit-reset": "1, 2678400",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
searchBrave("burst one", { numResults: 1 }),
|
|
||||||
searchBrave("burst two", { numResults: 1 }),
|
|
||||||
searchBrave("burst three", { numResults: 1 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(requestStartedAt.length, 3);
|
|
||||||
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
|
|
||||||
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
const requestStartedAt: number[] = [];
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
requestStartedAt.push(Date.now());
|
|
||||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
|
|
||||||
"x-ratelimit-remaining": "49, 0",
|
|
||||||
"x-ratelimit-reset": "1, 2678400",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await searchBrave("learn upgraded policy", { numResults: 1 });
|
|
||||||
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
|
|
||||||
|
|
||||||
assert.equal(requestStartedAt.length, 9);
|
|
||||||
const burstStartedAt = requestStartedAt.slice(1);
|
|
||||||
for (let index = 1; index < burstStartedAt.length; index += 1) {
|
|
||||||
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
|
|
||||||
}
|
|
||||||
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave retries 429 responses after the burst window resets", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
let fetchCount = 0;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
fetchCount += 1;
|
|
||||||
const rateLimitHeaders = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
|
||||||
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
|
|
||||||
"x-ratelimit-reset": "1, 2678400",
|
|
||||||
};
|
|
||||||
if (fetchCount === 1) {
|
|
||||||
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
|
|
||||||
status: 429,
|
|
||||||
headers: rateLimitHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await searchBrave("retry burst", { numResults: 1 });
|
|
||||||
assert.equal(fetchCount, 2);
|
|
||||||
assert.ok(Date.now() - startedAt >= 1_000);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave does not wait for exhausted long-term quotas", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
|
||||||
"x-ratelimit-remaining": "0, 0",
|
|
||||||
"x-ratelimit-reset": "1, 100000",
|
|
||||||
},
|
|
||||||
})) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await assert.rejects(
|
|
||||||
() => searchBrave("quota exhausted", { numResults: 1 }),
|
|
||||||
/rate limit quota is exhausted beyond the retry window/
|
|
||||||
);
|
|
||||||
assert.ok(Date.now() - startedAt < 1_000);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
|
|
||||||
let fetchCount = 0;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
fetchCount += 1;
|
|
||||||
return new Response(JSON.stringify({ web: { results: [] } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
|
|
||||||
"x-ratelimit-remaining": "0, 0",
|
|
||||||
"x-ratelimit-reset": "1, 100000",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await searchBrave("last allowed query", { numResults: 1 });
|
|
||||||
await assert.rejects(
|
|
||||||
() => searchBrave("over quota query", { numResults: 1 }),
|
|
||||||
/long-term quota is exhausted/
|
|
||||||
);
|
|
||||||
assert.equal(fetchCount, 1);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
env.BRAVE_SEARCH_API_KEY = originalApiKey;
|
|
||||||
resetBraveRateLimitStateForTests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import Fastify from "fastify";
|
|
||||||
import sensible from "@fastify/sensible";
|
|
||||||
|
|
||||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
|
||||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
|
||||||
process.env.OPENAI_API_KEY = "";
|
|
||||||
delete process.env.ADMIN_TOKEN;
|
|
||||||
|
|
||||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
|
||||||
cwd: serverRoot,
|
|
||||||
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
|
|
||||||
import("../src/routes.js"),
|
|
||||||
import("../src/db.js"),
|
|
||||||
]);
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(sensible);
|
|
||||||
await registerRoutes(app);
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
test.after(async () => {
|
|
||||||
await app.close();
|
|
||||||
await prisma.$disconnect();
|
|
||||||
rmSync(databaseDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forks copy bounded history and keep every child grouped under the root", async () => {
|
|
||||||
const timestamp = new Date("2026-08-16T12:00:00.000Z");
|
|
||||||
const source = await prisma.chat.create({
|
|
||||||
data: {
|
|
||||||
title: "Planning session",
|
|
||||||
initiatedProvider: "openai",
|
|
||||||
initiatedModel: "gpt-4.1-mini",
|
|
||||||
lastUsedProvider: "anthropic",
|
|
||||||
lastUsedModel: "claude-sonnet-4-20250514",
|
|
||||||
additionalSystemPrompt: "Keep answers concise.",
|
|
||||||
enabledTools: ["web_search"],
|
|
||||||
messages: {
|
|
||||||
create: [
|
|
||||||
{
|
|
||||||
id: "source-message-0001",
|
|
||||||
createdAt: timestamp,
|
|
||||||
role: "user",
|
|
||||||
content: "Plan a trip",
|
|
||||||
metadata: {
|
|
||||||
clientRequestId: "source-request",
|
|
||||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "source-message-0002",
|
|
||||||
createdAt: timestamp,
|
|
||||||
role: "assistant",
|
|
||||||
content: "First assistant response",
|
|
||||||
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "source-message-0003",
|
|
||||||
createdAt: timestamp,
|
|
||||||
role: "user",
|
|
||||||
content: "Only visible in a whole-chat fork",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
calls: {
|
|
||||||
create: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-4.1-mini",
|
|
||||||
request: { input: "Plan a trip" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const forkResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${source.id}/fork`,
|
|
||||||
payload: { messageId: source.messages[1].id },
|
|
||||||
});
|
|
||||||
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
|
|
||||||
const forkSummary = forkResponse.json().chat;
|
|
||||||
assert.equal(forkSummary.parentChatId, source.id);
|
|
||||||
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
|
|
||||||
assert.equal(forkSummary.titleGenerationPending, true);
|
|
||||||
assert.equal(forkSummary.starred, false);
|
|
||||||
|
|
||||||
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
|
|
||||||
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
|
|
||||||
const fork = detailResponse.json().chat;
|
|
||||||
assert.deepEqual(
|
|
||||||
fork.messages.map((message: any) => [message.role, message.content]),
|
|
||||||
[
|
|
||||||
["user", "Plan a trip"],
|
|
||||||
["assistant", "First assistant response"],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert.notEqual(fork.messages[0].id, source.messages[0].id);
|
|
||||||
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
|
|
||||||
assert.deepEqual(fork.messages[0].metadata, {
|
|
||||||
attachments: [{ kind: "text", filename: "notes.txt" }],
|
|
||||||
});
|
|
||||||
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
|
|
||||||
assert.equal(fork.initiatedProvider, "openai");
|
|
||||||
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
|
|
||||||
assert.equal(fork.lastUsedProvider, "anthropic");
|
|
||||||
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
|
|
||||||
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
|
|
||||||
assert.deepEqual(fork.enabledTools, ["web_search"]);
|
|
||||||
assert.deepEqual(fork.calls, []);
|
|
||||||
|
|
||||||
const childForkResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${forkSummary.id}/fork`,
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
|
|
||||||
const childFork = childForkResponse.json().chat;
|
|
||||||
assert.equal(childFork.parentChatId, source.id);
|
|
||||||
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
|
|
||||||
assert.equal(childFork.titleGenerationPending, true);
|
|
||||||
|
|
||||||
const wholeForkResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${source.id}/fork`,
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
|
|
||||||
const wholeFork = wholeForkResponse.json().chat;
|
|
||||||
assert.equal(wholeFork.parentChatId, source.id);
|
|
||||||
assert.equal(wholeFork.title, "Fork of Planning session");
|
|
||||||
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
|
|
||||||
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
|
|
||||||
assert.deepEqual(
|
|
||||||
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
|
|
||||||
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
|
|
||||||
);
|
|
||||||
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
|
|
||||||
|
|
||||||
const suggestedTitleResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/v1/chats/title/suggest",
|
|
||||||
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
|
|
||||||
});
|
|
||||||
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
|
|
||||||
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
|
|
||||||
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
|
|
||||||
|
|
||||||
const repeatedTitleResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/v1/chats/title/suggest",
|
|
||||||
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
|
|
||||||
});
|
|
||||||
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
|
|
||||||
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
|
|
||||||
|
|
||||||
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
|
|
||||||
where: { id: source.id },
|
|
||||||
include: { messages: true, calls: true },
|
|
||||||
});
|
|
||||||
assert.equal(sourceAfterForks.messages.length, 3);
|
|
||||||
assert.equal(sourceAfterForks.calls.length, 1);
|
|
||||||
|
|
||||||
const manualTitleResponse = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/v1/chats/${forkSummary.id}`,
|
|
||||||
payload: { title: "Manual branch title" },
|
|
||||||
});
|
|
||||||
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
|
|
||||||
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
|
|
||||||
|
|
||||||
const activeForkStream = registerActiveChatStream(wholeFork.id);
|
|
||||||
try {
|
|
||||||
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
|
||||||
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
|
|
||||||
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
|
|
||||||
|
|
||||||
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
|
|
||||||
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
|
|
||||||
} finally {
|
|
||||||
clearActiveChatStream(wholeFork.id, activeForkStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
|
|
||||||
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
|
|
||||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
|
|
||||||
|
|
||||||
const unrelated = await prisma.chat.create({
|
|
||||||
data: {
|
|
||||||
messages: { create: { role: "assistant", content: "Unrelated response" } },
|
|
||||||
},
|
|
||||||
include: { messages: true },
|
|
||||||
});
|
|
||||||
const countBeforeInvalidForks = await prisma.chat.count();
|
|
||||||
|
|
||||||
const wrongChatResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${source.id}/fork`,
|
|
||||||
payload: { messageId: unrelated.messages[0].id },
|
|
||||||
});
|
|
||||||
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
|
|
||||||
assert.equal(wrongChatResponse.json().message, "message not found in chat");
|
|
||||||
|
|
||||||
const nonAssistantResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${source.id}/fork`,
|
|
||||||
payload: { messageId: source.messages[0].id },
|
|
||||||
});
|
|
||||||
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
|
|
||||||
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
|
|
||||||
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
|
|
||||||
|
|
||||||
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
|
|
||||||
const concurrentDeleteResponses = await Promise.all([
|
|
||||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
|
||||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
|
||||||
]);
|
|
||||||
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
|
|
||||||
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
|
|
||||||
|
|
||||||
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
|
|
||||||
const concurrentChildResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/v1/chats/${concurrentRoot.id}/fork`,
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
|
|
||||||
const concurrentChild = concurrentChildResponse.json().chat;
|
|
||||||
const concurrentFamilyDeleteResponses = await Promise.all([
|
|
||||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
|
|
||||||
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
|
|
||||||
]);
|
|
||||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
|
|
||||||
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
|
|
||||||
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
|
|
||||||
|
|
||||||
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
|
||||||
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
|
|
||||||
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
|
|
||||||
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
|
|
||||||
});
|
|
||||||
@@ -140,94 +140,6 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
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,12 +27,6 @@ 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,10 +5,8 @@ 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.equal(toPrismaProvider("gemini"), "gemini");
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||||
assert.equal(fromPrismaProvider("gemini"), "gemini");
|
|
||||||
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), {
|
|
||||||
initiatedProvider: "hermes-agent",
|
initiatedProvider: "hermes-agent",
|
||||||
lastUsedProvider: "gemini",
|
lastUsedProvider: "xai",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import {
|
|
||||||
buildQuickQuestionMultiplexRequest,
|
|
||||||
QUICK_QUESTION_SYSTEM_PROMPT,
|
|
||||||
} from "../src/llm/quick-question.js";
|
|
||||||
|
|
||||||
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
|
||||||
const request = buildQuickQuestionMultiplexRequest({
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-4.1-mini",
|
|
||||||
question: "How do I reset my password?",
|
|
||||||
enabledTools: ["web_search"],
|
|
||||||
userLocation: "Los Angeles, CA",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(request.persist, false);
|
|
||||||
assert.equal(request.chatId, undefined);
|
|
||||||
assert.deepEqual(request.messages, [
|
|
||||||
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
|
||||||
{ role: "user", content: "How do I reset my password?" },
|
|
||||||
]);
|
|
||||||
assert.deepEqual(request.enabledTools, ["web_search"]);
|
|
||||||
assert.equal(request.userLocation, "Los Angeles, CA");
|
|
||||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
|
||||||
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
|
||||||
});
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"];
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
+13
-36
@@ -42,13 +42,12 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
const 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"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ 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) {
|
||||||
@@ -81,7 +79,6 @@ 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 "";
|
||||||
}
|
}
|
||||||
@@ -127,23 +124,17 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||||
const chatsById = new Map<string, ChatSummary>();
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type === "chat") chatsById.set(item.id, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (item.type === "chat") {
|
if (item.type === "chat") {
|
||||||
const chat = item;
|
const chat = item;
|
||||||
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
|
|
||||||
return {
|
return {
|
||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
starred: starOwner.starred,
|
starred: chat.starred,
|
||||||
starredAt: starOwner.starredAt,
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -275,7 +266,6 @@ 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);
|
||||||
@@ -985,11 +975,11 @@ async function main() {
|
|||||||
focusComposer();
|
focusComposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
async function maybeSuggestTitle(chatId: string, content: string) {
|
||||||
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
const chatSummary = chats.find((chat) => chat.id === chatId);
|
||||||
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||||
|
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
|
||||||
|
|
||||||
const chatId = chat.id;
|
|
||||||
pendingTitleGeneration.add(chatId);
|
pendingTitleGeneration.add(chatId);
|
||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
@@ -999,8 +989,6 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
parentChatId: updated.parentChatId,
|
|
||||||
titleGenerationPending: updated.titleGenerationPending,
|
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1057,8 +1045,6 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
parentChatId: chat.parentChatId,
|
|
||||||
titleGenerationPending: chat.titleGenerationPending,
|
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -1078,13 +1064,13 @@ async function main() {
|
|||||||
throw new Error("Unable to initialize chat");
|
throw new Error("Unable to initialize chat");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void maybeSuggestTitle(chatId, content);
|
||||||
|
|
||||||
let baseChat = selectedChat;
|
let baseChat = selectedChat;
|
||||||
if (!baseChat || baseChat.id !== chatId) {
|
if (!baseChat || baseChat.id !== chatId) {
|
||||||
baseChat = await api.getChat(chatId);
|
baseChat = await api.getChat(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void maybeSuggestTitle(baseChat, content);
|
|
||||||
|
|
||||||
const requestMessages: CompletionRequestMessage[] = [
|
const requestMessages: CompletionRequestMessage[] = [
|
||||||
...baseChat.messages
|
...baseChat.messages
|
||||||
.filter((message) => !isToolCallLogMessage(message))
|
.filter((message) => !isToolCallLogMessage(message))
|
||||||
@@ -1402,8 +1388,6 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
parentChatId: updated.parentChatId,
|
|
||||||
titleGenerationPending: updated.titleGenerationPending,
|
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
initiatedProvider: updated.initiatedProvider,
|
initiatedProvider: updated.initiatedProvider,
|
||||||
initiatedModel: updated.initiatedModel,
|
initiatedModel: updated.initiatedModel,
|
||||||
@@ -1417,16 +1401,12 @@ async function main() {
|
|||||||
async function handleToggleStarSelection() {
|
async function handleToggleStarSelection() {
|
||||||
if (!selectedItem) return;
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||||
|
const nextStarred = !currentItem?.starred;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (selectedItem.kind === "chat") {
|
if (selectedItem.kind === "chat") {
|
||||||
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
|
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||||
const selectedParentChatId = selectedChat?.id === selectedItem.id
|
|
||||||
? selectedChat.parentChatId
|
|
||||||
: selectedSummary?.parentChatId;
|
|
||||||
const rootChatId = selectedParentChatId ?? selectedItem.id;
|
|
||||||
const rootSummary = chats.find((chat) => chat.id === rootChatId);
|
|
||||||
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
|
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
@@ -1437,8 +1417,6 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
parentChatId: updated.parentChatId,
|
|
||||||
titleGenerationPending: updated.titleGenerationPending,
|
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1449,8 +1427,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||||
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
|
||||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||||
|
|||||||
+1
-5
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -13,8 +13,6 @@ export type ModelCatalogResponse = {
|
|||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parentChatId: string | null;
|
|
||||||
titleGenerationPending: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -70,8 +68,6 @@ export type ToolCallEvent = {
|
|||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parentChatId: string | null;
|
|
||||||
titleGenerationPending: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"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": {
|
||||||
|
|||||||
+2
-16
@@ -3,24 +3,10 @@ self.addEventListener("install", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(self.clients.claim());
|
||||||
(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(new Request(event.request, { cache: "no-store" })));
|
event.respondWith(fetch(event.request));
|
||||||
});
|
});
|
||||||
|
|||||||
+202
-846
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
||||||
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type MutableValueRef = {
|
|
||||||
current: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
draftRef: MutableValueRef;
|
|
||||||
draftRevision: number;
|
|
||||||
error: string | null;
|
|
||||||
isSearchMode: boolean;
|
|
||||||
isSending: boolean;
|
|
||||||
pendingAttachmentCount: number;
|
|
||||||
attachmentButtonDisabled: boolean;
|
|
||||||
onOpenAttachmentPicker: () => void;
|
|
||||||
onPaste: (event: ClipboardEvent) => void;
|
|
||||||
onSend: (draft: string) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIRROR_SENTINEL = "\u200b";
|
|
||||||
const HAS_NON_WHITESPACE = /\S/;
|
|
||||||
|
|
||||||
export function ChatComposer({
|
|
||||||
draftRef,
|
|
||||||
draftRevision,
|
|
||||||
error,
|
|
||||||
isSearchMode,
|
|
||||||
isSending,
|
|
||||||
pendingAttachmentCount,
|
|
||||||
attachmentButtonDisabled,
|
|
||||||
onOpenAttachmentPicker,
|
|
||||||
onPaste,
|
|
||||||
onSend,
|
|
||||||
}: Props) {
|
|
||||||
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
|
|
||||||
const textareaContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const mirrorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
|
|
||||||
const hasDraftRef = useRef(hasDraft);
|
|
||||||
|
|
||||||
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
|
|
||||||
|
|
||||||
const updateMirror = (value: string) => {
|
|
||||||
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
|
|
||||||
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateHasDraft = (value: string) => {
|
|
||||||
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
|
|
||||||
if (nextHasDraft !== hasDraftRef.current) {
|
|
||||||
hasDraftRef.current = nextHasDraft;
|
|
||||||
setHasDraft(nextHasDraft);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const value = draftRef.current;
|
|
||||||
const textarea = getTextarea();
|
|
||||||
if (textarea && textarea.value !== value) {
|
|
||||||
textarea.value = value;
|
|
||||||
}
|
|
||||||
updateMirror(value);
|
|
||||||
updateHasDraft(value);
|
|
||||||
}, [draftRevision]);
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const textarea = getTextarea();
|
|
||||||
const draft = textarea?.value ?? draftRef.current;
|
|
||||||
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
|
|
||||||
if (isSending || !canSend) return;
|
|
||||||
|
|
||||||
draftRef.current = "";
|
|
||||||
if (textarea) textarea.value = "";
|
|
||||||
updateMirror("");
|
|
||||||
updateHasDraft("");
|
|
||||||
void onSend(draft);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={mirrorRef}
|
|
||||||
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
id="composer-input"
|
|
||||||
rows={1}
|
|
||||||
onInput={(event) => {
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
draftRef.current = value;
|
|
||||||
updateMirror(value);
|
|
||||||
updateHasDraft(value);
|
|
||||||
}}
|
|
||||||
onPaste={(event) => {
|
|
||||||
onPaste(event);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
|
||||||
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
|
||||||
disabled={isSending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
|
||||||
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
|
||||||
{!isSearchMode ? (
|
|
||||||
<Button
|
|
||||||
className="h-10 w-10 rounded-lg"
|
|
||||||
onClick={onOpenAttachmentPicker}
|
|
||||||
size="icon"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={attachmentButtonDisabled}
|
|
||||||
aria-label="Attach files"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
className="h-10 w-10 rounded-lg"
|
|
||||||
onClick={submit}
|
|
||||||
size="icon"
|
|
||||||
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
|
|
||||||
aria-label={isSearchMode ? "Search" : "Send message"}
|
|
||||||
>
|
|
||||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ type Props = {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
@@ -396,7 +395,7 @@ function ToolCallStack({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
@@ -468,11 +467,6 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||||
: "text-base leading-7 text-violet-50"
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
onContextMenu={
|
|
||||||
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
|
||||||
? (event) => onMessageContextMenu(event, message.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||||
{isPendingAssistant ? (
|
{isPendingAssistant ? (
|
||||||
@@ -484,7 +478,6 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
) : 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,7 +10,6 @@ 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) {
|
||||||
@@ -29,30 +28,17 @@ markdownRenderer.table = (token) => {
|
|||||||
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function setNewTabLinkAttributes(currentNode: Element) {
|
function renderMarkdown(markdown: string) {
|
||||||
if (currentNode.tagName !== "A") return;
|
|
||||||
currentNode.setAttribute("target", "_blank");
|
|
||||||
currentNode.setAttribute("rel", "noopener noreferrer");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
|
|
||||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
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"] });
|
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) {
|
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: 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, openLinksInNewTab);
|
return renderMarkdown(prepared);
|
||||||
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
|
}, [markdown, mode, resolveCitationIndex]);
|
||||||
|
|
||||||
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-69
@@ -131,45 +131,6 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (horizontal-viewport-segments: 2) {
|
|
||||||
.app-safe-frame {
|
|
||||||
padding:
|
|
||||||
max(0.5rem, var(--safe-area-top))
|
|
||||||
max(0.5rem, var(--safe-area-right))
|
|
||||||
max(0.5rem, var(--safe-area-bottom))
|
|
||||||
max(0.5rem, var(--safe-area-left));
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
|
||||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
|
||||||
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-sidebar {
|
|
||||||
position: static;
|
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
transform: none;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-content {
|
|
||||||
min-width: 0;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
touch-action: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-sidebar-backdrop,
|
|
||||||
.workspace-sidebar-trigger {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||||
@@ -325,14 +286,6 @@ 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;
|
||||||
@@ -431,8 +384,7 @@ textarea {
|
|||||||
|
|
||||||
.md-content ul,
|
.md-content ul,
|
||||||
.md-content ol {
|
.md-content ol {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.65rem;
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -444,26 +396,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-58
@@ -1,8 +1,6 @@
|
|||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parentChatId: string | null;
|
|
||||||
titleGenerationPending: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -60,8 +58,6 @@ export type ToolCallEvent = {
|
|||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parentChatId: string | null;
|
|
||||||
titleGenerationPending: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -153,7 +149,7 @@ export type CompletionRequestMessage = {
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -191,14 +187,6 @@ type CompletionStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function dispatchCompletionStreamEvent(handlers: CompletionStreamHandlers, eventName: string, payload: any) {
|
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateChatRequest = {
|
type CreateChatRequest = {
|
||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
@@ -303,14 +291,6 @@ export async function getChat(chatId: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forkChat(chatId: string, messageId?: string) {
|
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(messageId ? { messageId } : {}),
|
|
||||||
});
|
|
||||||
return data.chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateChatTitle(chatId: string, title: string) {
|
export async function updateChatTitle(chatId: string, title: string) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -470,7 +450,6 @@ 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) {
|
||||||
@@ -487,9 +466,6 @@ 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";
|
||||||
@@ -529,10 +505,6 @@ 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(
|
||||||
@@ -556,14 +528,87 @@ export async function runSearchStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
if (!response.ok) {
|
||||||
|
const fallback = `${response.status} ${response.statusText}`;
|
||||||
|
let message = fallback;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { message?: string };
|
||||||
|
if (body.message) message = body.message;
|
||||||
|
} catch {
|
||||||
|
// keep fallback message
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No response stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let eventName = "message";
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
|
||||||
|
const flushEvent = () => {
|
||||||
|
if (!dataLines.length) {
|
||||||
|
eventName = "message";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataText = dataLines.join("\n");
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(dataText);
|
||||||
|
} catch {
|
||||||
|
payload = { message: dataText };
|
||||||
|
}
|
||||||
|
|
||||||
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 }) {
|
||||||
@@ -609,7 +654,6 @@ 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[];
|
||||||
@@ -635,34 +679,86 @@ export async function runCompletionStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
if (!response.ok) {
|
||||||
}
|
const fallback = `${response.status} ${response.statusText}`;
|
||||||
|
let message = fallback;
|
||||||
export async function runQuickQuestionStream(
|
try {
|
||||||
body: {
|
const body = (await response.json()) as { message?: string };
|
||||||
provider: Provider;
|
if (body.message) message = body.message;
|
||||||
model: string;
|
} catch {
|
||||||
question: string;
|
// keep fallback message
|
||||||
},
|
}
|
||||||
handlers: CompletionStreamHandlers,
|
throw new Error(message);
|
||||||
options?: { signal?: AbortSignal }
|
|
||||||
) {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
if (authToken) {
|
|
||||||
headers.set("Authorization", `Bearer ${authToken}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/v1/quick-questions/stream`, {
|
if (!response.body) {
|
||||||
method: "POST",
|
throw new Error("No response stream");
|
||||||
headers,
|
}
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: options?.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
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);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
|
||||||
|
dataLines = [];
|
||||||
|
eventName = "message";
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newlineIndex = buffer.indexOf("\n");
|
||||||
|
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const rawLine = buffer.slice(0, newlineIndex);
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
flushEvent();
|
||||||
|
} else if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
|
||||||
|
newlineIndex = buffer.indexOf("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.length) {
|
||||||
|
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
@@ -679,5 +775,11 @@ export async function attachCompletionStream(chatId: string, handlers: Completio
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
await readSseStream(response, (eventName, payload) => {
|
||||||
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
export type ForkGroupingItem = {
|
|
||||||
kind: "chat" | "search";
|
|
||||||
id: string;
|
|
||||||
parentChatId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChatTitleState = {
|
|
||||||
title: string | null;
|
|
||||||
titleGenerationPending: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ForkPresentationItem = ForkGroupingItem & {
|
|
||||||
updatedAt: string;
|
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function groupForkedChatItems<T extends ForkGroupingItem>(items: T[]): T[][] {
|
|
||||||
const groups = new Map<string, { firstIndex: number; items: Array<{ item: T; index: number }> }>();
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`;
|
|
||||||
const group = groups.get(groupKey);
|
|
||||||
if (group) {
|
|
||||||
group.items.push({ item, index });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
groups.set(groupKey, { firstIndex: index, items: [{ item, index }] });
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...groups.values()]
|
|
||||||
.sort((a, b) => a.firstIndex - b.firstIndex)
|
|
||||||
.map((group) =>
|
|
||||||
group.items
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null;
|
|
||||||
const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null;
|
|
||||||
if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1;
|
|
||||||
return a.index - b.index;
|
|
||||||
})
|
|
||||||
.map(({ item }) => item)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterSidebarItemsWithForkGroups<T extends ForkGroupingItem>(items: T[], matches: (item: T) => boolean): T[] {
|
|
||||||
return groupForkedChatItems(items)
|
|
||||||
.flatMap((group) => {
|
|
||||||
const matchingItems = group.filter(matches);
|
|
||||||
if (!matchingItems.length) return [];
|
|
||||||
const root = group.find((item) => item.kind === "chat" && item.parentChatId === null);
|
|
||||||
if (!root || matchingItems.includes(root)) return group;
|
|
||||||
return group.filter((item) => item === root || matchingItems.includes(item));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getForkGroupPresentation<T extends ForkPresentationItem>(groupItems: T[], allItems: T[]) {
|
|
||||||
const firstItem = groupItems[0];
|
|
||||||
if (!firstItem) return null;
|
|
||||||
|
|
||||||
const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null;
|
|
||||||
const familyItems = rootId
|
|
||||||
? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId))
|
|
||||||
: groupItems;
|
|
||||||
const updatedAt = familyItems.reduce(
|
|
||||||
(newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest),
|
|
||||||
firstItem.updatedAt
|
|
||||||
);
|
|
||||||
const starOwner = rootId
|
|
||||||
? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null)
|
|
||||||
: firstItem;
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedAt,
|
|
||||||
starred: starOwner?.starred ?? false,
|
|
||||||
starredAt: starOwner?.starredAt ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) {
|
|
||||||
if (!chat || isRequestInFlight) return false;
|
|
||||||
return chat.titleGenerationPending || !chat.title?.trim();
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { Provider } from "./api";
|
|
||||||
|
|
||||||
export function buildQuickQuestionRequest({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
content,
|
|
||||||
}: {
|
|
||||||
provider: Provider;
|
|
||||||
model: string;
|
|
||||||
content: string;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
question: content,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
+1
-4
@@ -2,10 +2,7 @@ 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
|
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||||
.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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import {
|
|
||||||
filterSidebarItemsWithForkGroups,
|
|
||||||
getForkGroupPresentation,
|
|
||||||
groupForkedChatItems,
|
|
||||||
shouldRequestChatTitle,
|
|
||||||
} from "../src/lib/chat-forking.ts";
|
|
||||||
|
|
||||||
const root = { kind: "chat", id: "root", parentChatId: null };
|
|
||||||
const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" };
|
|
||||||
const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" };
|
|
||||||
const search = { kind: "search", id: "search", parentChatId: null };
|
|
||||||
|
|
||||||
test("fork groups are positioned by their newest member and always render root first", () => {
|
|
||||||
assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [
|
|
||||||
[root, nestedFork, firstFork],
|
|
||||||
[search],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar filtering keeps a matching fork with its root", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"),
|
|
||||||
[root, nestedFork]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar filtering keeps all children when their root matches", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"),
|
|
||||||
[root, firstFork, nestedFork]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a filtered family keeps its full-family date and root-owned star state", () => {
|
|
||||||
const fullFamily = [
|
|
||||||
{ ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" },
|
|
||||||
{ ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null },
|
|
||||||
{ ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null },
|
|
||||||
];
|
|
||||||
const filteredFamily = [fullFamily[0], fullFamily[1]];
|
|
||||||
|
|
||||||
assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), {
|
|
||||||
updatedAt: "2026-08-16T00:00:00.000Z",
|
|
||||||
starred: true,
|
|
||||||
starredAt: "2026-08-15T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a fork placeholder title is replaced on its first submitted prompt", () => {
|
|
||||||
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true);
|
|
||||||
assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => {
|
|
||||||
assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true);
|
|
||||||
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false);
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts";
|
|
||||||
|
|
||||||
test("quick question requests use the dedicated server endpoint shape", () => {
|
|
||||||
const request = buildQuickQuestionRequest({
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-4.1-mini",
|
|
||||||
content: "How do I reset my password?",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(request, {
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-4.1-mini",
|
|
||||||
question: "How do I reset my password?",
|
|
||||||
});
|
|
||||||
assert.equal("additionalSystemPrompt" in request, false);
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/quick-question.ts","./src/lib/sidebar-selection.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/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user