Compare commits
21 Commits
ae783020ef
...
wip/spacer
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d69cb4979 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 | |||
| 195e157e1a | |||
| c5dbd12587 | |||
| be072fd46d | |||
| f514c42de6 | |||
| 70a60edf1c | |||
| 91ef28bf29 | |||
| bb713f8806 | |||
| e6cf344527 | |||
| 4bc0773d35 | |||
| d1140d21d4 | |||
| 0c0226e37e | |||
| 0b94d5b3fa | |||
| aff2531bf3 | |||
| ee8a93a8c4 | |||
| 53a3b722ec |
@@ -12,6 +12,9 @@ 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:-}
|
||||||
|
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||||
|
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||||
|
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
EXA_API_KEY: ${EXA_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:-}
|
||||||
|
|||||||
@@ -33,11 +33,29 @@ Chat upload limits:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"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 },
|
||||||
|
"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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Active Runs
|
||||||
|
|
||||||
|
### `GET /v1/active-runs`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chats": ["chat-id-with-active-stream"],
|
||||||
|
"searches": ["search-id-with-active-stream"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Lists in-memory chat/search streams that are still running on this server process.
|
||||||
|
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||||
|
- The lists are not durable across server restarts.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
@@ -49,7 +67,7 @@ Chat upload limits:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai",
|
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -136,7 +154,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -190,11 +208,12 @@ Behavior notes:
|
|||||||
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||||
- 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 `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 `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 `xai`, 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 tool calls for chat: `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` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL 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.
|
||||||
@@ -260,6 +279,32 @@ Search run notes:
|
|||||||
- Persists answer text/citations + ranked results.
|
- Persists answer text/citations + ranked results.
|
||||||
- If both search and answer fail, endpoint returns an error.
|
- If both search and answer fail, endpoint returns an error.
|
||||||
|
|
||||||
|
### `POST /v1/searches/:searchId/run/stream`
|
||||||
|
- Body: same as `POST /v1/searches/:searchId/run`
|
||||||
|
- Response: `text/event-stream`
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
|
||||||
|
- `search_error`: `{ "error": string }`
|
||||||
|
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
|
||||||
|
- `answer_error`: `{ "error": string }`
|
||||||
|
- terminal `done`: `{ "search": SearchDetail }`
|
||||||
|
- terminal `error`: `{ "message": string }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
|
||||||
|
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
|
||||||
|
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.
|
||||||
|
|
||||||
|
### `POST /v1/searches/:searchId/run/stream/attach`
|
||||||
|
- Body: none
|
||||||
|
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
|
||||||
|
- Not found: `404 { "message": "active search stream not found" }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||||
|
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.
|
||||||
|
|
||||||
## Type Shapes
|
## Type Shapes
|
||||||
|
|
||||||
`ChatSummary`
|
`ChatSummary`
|
||||||
@@ -269,9 +314,9 @@ Search run notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"initiatedProvider": "openai|anthropic|xai|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null"
|
"lastUsedModel": "string|null"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -317,9 +362,9 @@ Search run notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"initiatedProvider": "openai|anthropic|xai|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"messages": [Message]
|
"messages": [Message]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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`
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
@@ -20,7 +21,7 @@ Authentication:
|
|||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,23 @@ Notes:
|
|||||||
- 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.
|
||||||
- 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:
|
||||||
|
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||||
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
|
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
||||||
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
|
## Attach Endpoint
|
||||||
|
|
||||||
|
`POST /v1/chats/:chatId/stream/attach`
|
||||||
|
- Body: none.
|
||||||
|
- Response uses the same `text/event-stream` transport and event names as `POST /v1/chat-completions/stream`.
|
||||||
|
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
||||||
|
- Returns `404 { "message": "active chat stream not found" }` if no stream is currently active for that chat.
|
||||||
|
- Authentication is the same as all other API endpoints.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
@@ -134,8 +152,9 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
|
|
||||||
- `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.
|
||||||
- `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.
|
||||||
|
- `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.
|
||||||
- `xai`: 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`. 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`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, 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.
|
||||||
|
|||||||
@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- `just build` will:
|
- `just build` will:
|
||||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||||
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
||||||
|
- Preferred test command: `just test`
|
||||||
|
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
|
||||||
|
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
|
||||||
|
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
|
||||||
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
||||||
|
|
||||||
|
## Simulator Workflow
|
||||||
|
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
|
||||||
|
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
|
||||||
|
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## 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:
|
||||||
@@ -40,3 +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`
|
||||||
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
17
ios/Apps/Sybil/Info.plist
Normal file
17
ios/Apps/Sybil/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>net.buzzert.sybil2.quick-question</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Quick question</string>
|
||||||
|
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||||
|
<string>sparkles</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
Binary file not shown.
@@ -5,6 +5,8 @@ import UIKit
|
|||||||
@main
|
@main
|
||||||
struct SybilApp: App
|
struct SybilApp: App
|
||||||
{
|
{
|
||||||
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
SplitView()
|
SplitView()
|
||||||
@@ -14,3 +16,79 @@ struct SybilApp: App
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
|
options: UIScene.ConnectionOptions
|
||||||
|
) -> UISceneConfiguration {
|
||||||
|
let configuration = UISceneConfiguration(
|
||||||
|
name: "Default Configuration",
|
||||||
|
sessionRole: connectingSceneSession.role
|
||||||
|
)
|
||||||
|
configuration.delegateClass = SybilSceneDelegate.self
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
completionHandler: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||||
|
func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowScene(
|
||||||
|
_ windowScene: UIWindowScene,
|
||||||
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
completionHandler: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum SybilHomeScreenQuickActionHandler {
|
||||||
|
static func configureQuickActions() {
|
||||||
|
// The quick question action is static in Info.plist so it is available before first launch.
|
||||||
|
UIApplication.shared.shortcutItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||||
|
guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ targets:
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.4
|
MARKETING_VERSION: 1.7
|
||||||
CURRENT_PROJECT_VERSION: 5
|
CURRENT_PROJECT_VERSION: 8
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct SplitView: View {
|
public struct SplitView: View {
|
||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
|
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
|
@State private var quickQuestionFocusRequest = 0
|
||||||
|
@State private var hasPendingQuickQuestionPresentation = false
|
||||||
|
@State private var isQuickQuestionPresented = false
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||||
|
|
||||||
private var keyboardActions: SybilKeyboardActions? {
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||||
@@ -50,23 +55,51 @@ public struct SplitView: View {
|
|||||||
} else if horizontalSizeClass == .compact {
|
} else if horizontalSizeClass == .compact {
|
||||||
SybilPhoneShellView(viewModel: viewModel)
|
SybilPhoneShellView(viewModel: viewModel)
|
||||||
} else {
|
} else {
|
||||||
NavigationSplitView {
|
GeometryReader { proxy in
|
||||||
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
SybilSidebarView(viewModel: viewModel)
|
SybilSidebarView(viewModel: viewModel)
|
||||||
} detail: {
|
} detail: {
|
||||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
SybilWorkspaceView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
composerFocusRequest: composerFocusRequest,
|
||||||
|
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
||||||
|
onShowSidebar: showSidebar,
|
||||||
|
onRequestNewChat: {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
|
SybilQuickQuestionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
focusRequest: quickQuestionFocusRequest
|
||||||
|
)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
||||||
|
guard request > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueQuickQuestionPresentation()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
@@ -93,6 +126,38 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
||||||
|
return size.width < size.height ? .showSidebar : .hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSidebar() {
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) {
|
||||||
|
columnVisibility = .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueQuickQuestionPresentation() {
|
||||||
|
hasPendingQuickQuestionPresentation = true
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPendingQuickQuestionIfPossible() {
|
||||||
|
guard hasPendingQuickQuestionPresentation,
|
||||||
|
!viewModel.isCheckingSession,
|
||||||
|
viewModel.isAuthenticated
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
quickQuestionFocusRequest += 1
|
||||||
|
isQuickQuestionPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleQuickQuestionDismissed() {
|
||||||
|
viewModel.cancelQuickQuestion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chats
|
return response.chats
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String? = nil) async throws -> ChatSummary {
|
func createChat(
|
||||||
|
title: String? = nil,
|
||||||
|
provider: Provider? = nil,
|
||||||
|
model: String? = nil,
|
||||||
|
messages: [CompletionRequestMessage]? = nil
|
||||||
|
) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats",
|
"/v1/chats",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: AnyEncodable(ChatCreateBody(title: title)),
|
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
|
||||||
responseType: ChatCreateResponse.self
|
responseType: ChatCreateResponse.self
|
||||||
)
|
)
|
||||||
return response.chat
|
return response.chat
|
||||||
@@ -116,6 +121,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
|
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -133,43 +142,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "meta":
|
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
|
||||||
await onEvent(.meta(payload))
|
|
||||||
case "tool_call":
|
|
||||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
|
||||||
await onEvent(.toolCall(payload))
|
|
||||||
case "delta":
|
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
|
||||||
await onEvent(.delta(payload))
|
|
||||||
case "done":
|
|
||||||
do {
|
|
||||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
} catch {
|
|
||||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
|
||||||
SybilLog.warning(
|
|
||||||
SybilLog.network,
|
|
||||||
"Recovered chat stream done payload from concatenated SSE data"
|
|
||||||
)
|
|
||||||
await onEvent(.done(recovered))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/chats/\(chatID)/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching chat 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, "Attached chat stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -188,34 +189,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "search_results":
|
|
||||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchResults(payload))
|
|
||||||
case "search_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchError(payload))
|
|
||||||
case "answer":
|
|
||||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answer(payload))
|
|
||||||
case "answer_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answerError(payload))
|
|
||||||
case "done":
|
|
||||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Search stream completed")
|
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/searches/\(searchID)/run/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Attached search stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
private func request<Response: Decodable>(
|
private func request<Response: Decodable>(
|
||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
@@ -498,6 +500,75 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return try? Self.decodeJSON(type, from: data)
|
return try? Self.decodeJSON(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func handleCompletionStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "meta":
|
||||||
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
|
await onEvent(.meta(payload))
|
||||||
|
case "tool_call":
|
||||||
|
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||||
|
await onEvent(.toolCall(payload))
|
||||||
|
case "delta":
|
||||||
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
|
await onEvent(.delta(payload))
|
||||||
|
case "done":
|
||||||
|
do {
|
||||||
|
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
} catch {
|
||||||
|
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"Recovered chat stream done payload from concatenated SSE data"
|
||||||
|
)
|
||||||
|
await onEvent(.done(recovered))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleSearchStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "search_results":
|
||||||
|
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchResults(payload))
|
||||||
|
case "search_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchError(payload))
|
||||||
|
case "answer":
|
||||||
|
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answer(payload))
|
||||||
|
case "answer_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answerError(payload))
|
||||||
|
case "done":
|
||||||
|
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func flushSSEEvent(
|
private static func flushSSEEvent(
|
||||||
eventName: inout String,
|
eventName: inout String,
|
||||||
dataLines: inout [String]
|
dataLines: inout [String]
|
||||||
@@ -551,6 +622,7 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
|
|
||||||
struct CompletionStreamRequest: Codable, Sendable {
|
struct CompletionStreamRequest: Codable, Sendable {
|
||||||
var chatId: String?
|
var chatId: String?
|
||||||
|
var persist: Bool? = nil
|
||||||
var provider: Provider
|
var provider: Provider
|
||||||
var model: String
|
var model: String
|
||||||
var messages: [CompletionRequestMessage]
|
var messages: [CompletionRequestMessage]
|
||||||
@@ -558,6 +630,9 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
|
var provider: Provider?
|
||||||
|
var model: String?
|
||||||
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import Foundation
|
|||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
func listChats() async throws -> [ChatSummary]
|
func listChats() async throws -> [ChatSummary]
|
||||||
func createChat(title: String?) async throws -> ChatSummary
|
func createChat(
|
||||||
|
title: String?,
|
||||||
|
provider: Provider?,
|
||||||
|
model: String?,
|
||||||
|
messages: [CompletionRequestMessage]?
|
||||||
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
@@ -13,13 +18,28 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
func deleteSearch(searchID: String) async throws
|
func deleteSearch(searchID: String) async throws
|
||||||
func listModels() async throws -> ModelCatalogResponse
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SybilAPIClienting {
|
||||||
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
|
try await createChat(title: title, provider: nil, model: nil, messages: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ struct SybilChatTranscriptView: View {
|
|||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
@State private var hasHandledInitialTranscriptScroll = false
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var tailSpacerHeight: CGFloat = 0
|
||||||
|
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
|
||||||
|
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
messages.contains { message in
|
messages.contains { message in
|
||||||
@@ -15,66 +18,66 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
|
ForEach(messages.reversed()) { message in
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background {
|
||||||
|
if isStreamingPendingAssistant(message) {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: SybilPendingAssistantHeightPreferenceKey.self,
|
||||||
|
value: proxy.size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scaleEffect(x: 1, y: -1)
|
||||||
|
}
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
if isLoading && messages.isEmpty {
|
||||||
Text("Loading messages…")
|
Text("Loading messages…")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(messages) { message in
|
|
||||||
MessageBubble(message: message, isSending: isSending)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.id(message.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSending && !hasPendingAssistant {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(SybilTheme.textMuted)
|
|
||||||
Text("Assistant is typing…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.id("typing-indicator")
|
|
||||||
}
|
|
||||||
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 2)
|
|
||||||
.id("chat-bottom-anchor")
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(with: proxy, animated: false)
|
onViewportHeightChange?(proxy.size.height)
|
||||||
}
|
}
|
||||||
.onChange(of: messages.map(\.id)) { _, _ in
|
.onChange(of: proxy.size.height) { _, height in
|
||||||
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
|
onViewportHeightChange?(height)
|
||||||
hasHandledInitialTranscriptScroll = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: isSending) { _, _ in
|
|
||||||
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
|
||||||
|
onPendingAssistantHeightChange?(height)
|
||||||
|
}
|
||||||
|
.scaleEffect(x: 1, y: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
|
||||||
|
isSending && message.id.hasPrefix("temp-assistant-")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
|
||||||
if animated {
|
static let defaultValue: CGFloat = 0
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
}
|
value = max(value, nextValue())
|
||||||
} else {
|
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.vertical, isUser ? 13 : 2)
|
||||||
|
.textSelection(.enabled)
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
if isUser {
|
if isUser {
|
||||||
@@ -266,6 +270,7 @@ private struct ToolCallActivityChip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case openai
|
||||||
case anthropic
|
case anthropic
|
||||||
case xai
|
case xai
|
||||||
|
case hermesAgent = "hermes-agent"
|
||||||
|
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
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 .hermesAgent: return "Hermes Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,6 +356,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var results: [SearchResultItem]
|
public var results: [SearchResultItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
|
||||||
|
public var chats: [String]
|
||||||
|
public var searches: [String]
|
||||||
|
|
||||||
|
public init(chats: [String] = [], searches: [String] = []) {
|
||||||
|
self.chats = chats
|
||||||
|
self.searches = searches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SearchRunRequest: Codable, Sendable {
|
public struct SearchRunRequest: Codable, Sendable {
|
||||||
public var query: String?
|
public var query: String?
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -394,8 +406,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct CompletionStreamMeta: Codable, Sendable {
|
public struct CompletionStreamMeta: Codable, Sendable {
|
||||||
public var chatId: String
|
public var chatId: String?
|
||||||
public var callId: String
|
public var callId: String?
|
||||||
public var provider: Provider
|
public var provider: Provider
|
||||||
public var model: String
|
public var model: String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,31 +22,71 @@ enum PhoneRoute: Hashable {
|
|||||||
|
|
||||||
struct SybilPhoneShellView: View {
|
struct SybilPhoneShellView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@State private var path: [PhoneRoute] = []
|
@State private var route: PhoneRoute = .draftChat
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
|
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
||||||
|
@State private var isSidebarOverlayPresented = false
|
||||||
|
@State private var sidebarSwipeOffset: CGFloat = 0
|
||||||
|
@State private var sidebarSwipeIsActive = false
|
||||||
|
@State private var sidebarSwipeIsCompleting = false
|
||||||
|
@State private var sidebarSwipeHasLatched = false
|
||||||
|
@State private var sidebarHighlightSelection: SidebarSelection?
|
||||||
|
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||||
|
@State private var openingSelectionRequestID: UUID?
|
||||||
|
|
||||||
|
private var canRecognizeSidebarSwipe: Bool {
|
||||||
|
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarOverlayProgress: CGFloat {
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return SidebarOverlaySwipeMetrics.progress(
|
||||||
|
for: sidebarSwipeOffset,
|
||||||
|
width: phoneStackWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldRenderSidebarOverlay: Bool {
|
||||||
|
isSidebarOverlayPresented ||
|
||||||
|
sidebarSwipeIsActive ||
|
||||||
|
sidebarSwipeIsCompleting ||
|
||||||
|
sidebarOverlayProgress > 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentRouteSelection: SidebarSelection? {
|
||||||
|
switch route {
|
||||||
|
case let .chat(chatID):
|
||||||
|
return .chat(chatID)
|
||||||
|
case let .search(searchID):
|
||||||
|
return .search(searchID)
|
||||||
|
case .draftChat, .draftSearch, .settings:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var highlightedSidebarSelection: SidebarSelection? {
|
||||||
|
sidebarHighlightSelection ?? currentRouteSelection
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
GeometryReader { proxy in
|
||||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
phoneStack(width: proxy.size.width)
|
||||||
.navigationTitle("")
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.onAppear {
|
||||||
.toolbar {
|
updatePhoneStackWidth(proxy.size.width)
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
SybilWordmark(size: 18)
|
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: proxy.size.width) { _, width in
|
||||||
.navigationDestination(for: PhoneRoute.self) { route in
|
updatePhoneStackWidth(width)
|
||||||
SybilPhoneDestinationView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
path: $path,
|
|
||||||
composerFocusRequest: $composerFocusRequest,
|
|
||||||
route: route
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
|
.animation(.easeOut(duration: 0.22), value: route)
|
||||||
|
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
@@ -60,8 +100,8 @@ struct SybilPhoneShellView: View {
|
|||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
refreshCollections: path.isEmpty,
|
refreshCollections: isSidebarOverlayPresented,
|
||||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
@@ -72,11 +112,383 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func phoneStack(width: CGFloat) -> some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
phoneWorkspaceLayer
|
||||||
|
.zIndex(0)
|
||||||
|
|
||||||
|
phoneSidebarOverlayLayer(width: width)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phoneWorkspaceLayer: some View {
|
||||||
|
SybilPhoneDestinationView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
composerFocusRequest: $composerFocusRequest,
|
||||||
|
route: route,
|
||||||
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
|
onShowSidebar: showSidebarOverlay
|
||||||
|
)
|
||||||
|
.background(SybilTheme.background)
|
||||||
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
|
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
|
||||||
|
.allowsHitTesting(!shouldRenderSidebarOverlay)
|
||||||
|
.background {
|
||||||
|
sidebarSwipeInstaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
phoneOverlayTopBar
|
||||||
|
|
||||||
|
SybilPhoneSidebarRoot(
|
||||||
|
viewModel: viewModel,
|
||||||
|
highlightedSelection: highlightedSidebarSelection,
|
||||||
|
onSelect: openSidebarSelection,
|
||||||
|
onRoute: showRouteAndClearSidebarHighlight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.opacity(sidebarOverlayProgress)
|
||||||
|
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
|
||||||
|
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
|
||||||
|
.allowsHitTesting(isSidebarOverlayPresented)
|
||||||
|
.accessibilityHidden(!isSidebarOverlayPresented)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarSwipeInstaller: some View {
|
||||||
|
WorkspaceSwipePanInstaller(
|
||||||
|
direction: .right,
|
||||||
|
isEnabled: canRecognizeSidebarSwipe,
|
||||||
|
onBegan: { width in
|
||||||
|
beginSidebarSwipe(containerWidth: width)
|
||||||
|
},
|
||||||
|
onChanged: { translationX, width in
|
||||||
|
updateSidebarSwipe(with: translationX, containerWidth: width)
|
||||||
|
},
|
||||||
|
onEnded: { translationX, width, velocityX, didFinish in
|
||||||
|
finishSidebarSwipe(
|
||||||
|
translationX: translationX,
|
||||||
|
containerWidth: width,
|
||||||
|
velocityX: velocityX,
|
||||||
|
didFinish: didFinish
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
|
||||||
|
guard !isSidebarOverlayPresented else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startNewChatFromDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phoneOverlayTopBar: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
SybilWordmark(size: 21)
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
hideSidebarOverlay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right.2")
|
||||||
|
.font(.system(size: 21, weight: .bold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.frame(width: 54, height: 54)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.fill(SybilTheme.surface.opacity(0.76))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Hide conversations")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background {
|
||||||
|
SybilPhoneOverlayBlurBand(edge: .top)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePhoneStackWidth(_ width: CGFloat) {
|
||||||
|
phoneStackWidth = max(width, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNewChatFromDestination() {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
showRoute(.draftChat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||||
|
let update = {
|
||||||
|
route = nextRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
update()
|
||||||
|
isSidebarOverlayPresented = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
||||||
|
showRoute(nextRoute)
|
||||||
|
clearSidebarHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSidebarOverlay() {
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = true
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideSidebarOverlay() {
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = false
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||||
|
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestID = UUID()
|
||||||
|
openingSelectionRequestID = requestID
|
||||||
|
setSidebarHighlight(selection)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await viewModel.selectForNavigation(selection)
|
||||||
|
guard openingSelectionRequestID == requestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showRoute(PhoneRoute.from(selection: selection))
|
||||||
|
openingSelectionRequestID = nil
|
||||||
|
clearSidebarHighlight(selection, after: .milliseconds(260))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSidebarHighlight(_ selection: SidebarSelection) {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
sidebarHighlightSelection = selection
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
sidebarHighlightClearTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: delay)
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
sidebarHighlightSelection == selection,
|
||||||
|
openingSelectionRequestID == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sidebarHighlightSelection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearSidebarHighlight() {
|
||||||
|
sidebarHighlightClearTask?.cancel()
|
||||||
|
openingSelectionRequestID = nil
|
||||||
|
sidebarHighlightSelection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginSidebarSwipe(containerWidth: CGFloat) {
|
||||||
|
let update = {
|
||||||
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
|
sidebarSwipeIsActive = true
|
||||||
|
sidebarSwipeHasLatched = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||||
|
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||||
|
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
|
offset: nextOffset,
|
||||||
|
width: containerWidth,
|
||||||
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
|
)
|
||||||
|
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction) {
|
||||||
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
|
sidebarSwipeOffset = nextOffset
|
||||||
|
sidebarSwipeHasLatched = nextLatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishSidebarSwipe(
|
||||||
|
translationX: CGFloat,
|
||||||
|
containerWidth: CGFloat,
|
||||||
|
velocityX: CGFloat,
|
||||||
|
didFinish: Bool
|
||||||
|
) {
|
||||||
|
guard sidebarSwipeIsActive else {
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||||
|
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
|
offset: finalOffset,
|
||||||
|
width: containerWidth,
|
||||||
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
|
)
|
||||||
|
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
||||||
|
|
||||||
|
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
||||||
|
offset: finalOffset,
|
||||||
|
velocityX: velocityX,
|
||||||
|
width: containerWidth,
|
||||||
|
isLatched: finalLatched
|
||||||
|
) {
|
||||||
|
completeSidebarSwipe()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeSidebarSwipe() {
|
||||||
|
guard !sidebarSwipeIsCompleting else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarSwipeIsCompleting = true
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = true
|
||||||
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||||
|
let currentOffset = sidebarSwipeOffset
|
||||||
|
let reset = {
|
||||||
|
sidebarSwipeOffset = 0
|
||||||
|
sidebarSwipeIsActive = false
|
||||||
|
sidebarSwipeIsCompleting = false
|
||||||
|
sidebarSwipeHasLatched = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(
|
||||||
|
SidebarOverlaySwipeMetrics.springAnimation(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: 0,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SidebarOverlaySwipeMetrics {
|
||||||
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
BackSwipeMetrics.progress(for: offset, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||||
|
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||||
|
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
|
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||||
|
(1 - min(max(progress, 0), 1)) * 18
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||||
|
min(max(progress, 0), 1) * 14
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
|
||||||
|
1 - (min(max(progress, 0), 1) * 0.22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneOverlayBlurBand: View {
|
||||||
|
var edge: VerticalEdge
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.opacity(0.34)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: gradientColors,
|
||||||
|
startPoint: edge == .top ? .top : .bottom,
|
||||||
|
endPoint: edge == .top ? .bottom : .top
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gradientColors: [Color] {
|
||||||
|
[
|
||||||
|
Color.black.opacity(0.94),
|
||||||
|
SybilTheme.background.opacity(0.78),
|
||||||
|
Color.black.opacity(0)
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRoot: View {
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@Binding var path: [PhoneRoute]
|
var highlightedSelection: SidebarSelection?
|
||||||
|
var onSelect: (SidebarSelection) -> Void
|
||||||
|
var onRoute: (PhoneRoute) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -92,50 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: { item in
|
||||||
.tint(SybilTheme.primary)
|
highlightedSelection == item.selection
|
||||||
Text("Loading conversations…")
|
},
|
||||||
.font(.sybil(.footnote))
|
onSelect: { item in
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
onSelect(item.selection)
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
|
||||||
SybilPhoneSidebarRow(item: item)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -150,19 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
path = [.settings]
|
viewModel.openSettings()
|
||||||
|
onRoute(.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
path = [.draftSearch]
|
onRoute(.draftSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
path = [.draftChat]
|
onRoute(.draftChat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -200,83 +578,24 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRow: View {
|
|
||||||
var item: SidebarItem
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneDestinationView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@Binding var path: [PhoneRoute]
|
|
||||||
@Binding var composerFocusRequest: Int
|
@Binding var composerFocusRequest: Int
|
||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
|
let onRequestNewChat: (() -> Void)?
|
||||||
|
let onShowSidebar: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
composerFocusRequest: composerFocusRequest,
|
composerFocusRequest: composerFocusRequest,
|
||||||
usesCustomChatNavigation: route.isChatTranscript
|
navigationLeadingControl: .showSidebar,
|
||||||
) {
|
onShowSidebar: onShowSidebar,
|
||||||
viewModel.startNewChat()
|
onRequestBack: onRequestBack,
|
||||||
composerFocusRequest += 1
|
onRequestNewChat: onRequestNewChat
|
||||||
if path.isEmpty {
|
)
|
||||||
path = [.draftChat]
|
|
||||||
} else {
|
|
||||||
path[path.index(before: path.endIndex)] = .draftChat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
applyRoute()
|
applyRoute()
|
||||||
}
|
}
|
||||||
@@ -285,8 +604,14 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
private func applyRoute() {
|
private func applyRoute() {
|
||||||
switch route {
|
switch route {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.chat(chatID))
|
viewModel.select(.chat(chatID))
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
@@ -297,14 +622,3 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PhoneRoute {
|
|
||||||
var isChatTranscript: Bool {
|
|
||||||
switch self {
|
|
||||||
case .chat, .draftChat:
|
|
||||||
return true
|
|
||||||
case .search, .draftSearch, .settings:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SybilHomeScreenQuickAction {
|
||||||
|
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SybilQuickActionRouter: ObservableObject {
|
||||||
|
public static let shared = SybilQuickActionRouter()
|
||||||
|
|
||||||
|
@Published public private(set) var quickQuestionPresentationRequest = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func requestQuickQuestionPresentation() {
|
||||||
|
quickQuestionPresentationRequest += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
297
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
297
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilQuickQuestionView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var focusRequest: Int
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@FocusState private var promptFocused: Bool
|
||||||
|
|
||||||
|
private var hasAnswerContent: Bool {
|
||||||
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header
|
||||||
|
|
||||||
|
answerArea
|
||||||
|
|
||||||
|
composer
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.background(SybilTheme.backgroundGradient)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.task(id: focusRequest) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(260))
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
promptFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 21, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
|
||||||
|
Text("Quick question")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var answerArea: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if hasAnswerContent {
|
||||||
|
ForEach(viewModel.quickQuestionMessages) { message in
|
||||||
|
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.quickQuestionError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.black.opacity(0.36))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
|
TextField(
|
||||||
|
"Ask anything...",
|
||||||
|
text: Binding(
|
||||||
|
get: { viewModel.quickQuestionPrompt },
|
||||||
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
|
),
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.focused($promptFocused)
|
||||||
|
.font(.body)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.autocorrectionDisabled(false)
|
||||||
|
.lineLimit(1 ... 6)
|
||||||
|
.submitLabel(.send)
|
||||||
|
.onSubmit(submitQuestion)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.composerGradient)
|
||||||
|
.opacity(0.98)
|
||||||
|
)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Button(action: submitQuestion) {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
viewModel.canSendQuickQuestion
|
||||||
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!viewModel.canSendQuickQuestion)
|
||||||
|
.accessibilityLabel("Ask quick question")
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var convertButton: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||||
|
if didConvert {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Chat", systemImage: "bubble.left")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.disabled(!viewModel.canConvertQuickQuestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
providerMenu
|
||||||
|
modelMenu
|
||||||
|
convertButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.providerOptions, id: \.self) { provider in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionProvider(provider)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionProvider == provider {
|
||||||
|
Label(provider.displayName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(provider.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelMenu: some View {
|
||||||
|
Menu {
|
||||||
|
if viewModel.quickQuestionProviderModelOptions.isEmpty {
|
||||||
|
Text("No models")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
|
||||||
|
Button {
|
||||||
|
viewModel.setQuickQuestionModel(model)
|
||||||
|
} label: {
|
||||||
|
if viewModel.quickQuestionModel == model {
|
||||||
|
Label(model, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
|
||||||
|
.accessibilityLabel("Quick question model")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitQuestion() {
|
||||||
|
_ = viewModel.sendQuickQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionPickerPill: View {
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickQuestionMessageView: View {
|
||||||
|
var message: Message
|
||||||
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var isPendingAssistant: Bool {
|
||||||
|
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||||
|
isSending &&
|
||||||
|
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let metadata = message.toolCallMetadata {
|
||||||
|
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else if isPendingAssistant {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Thinking...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Markdown(message.content)
|
||||||
|
.font(.body)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.text.opacity(0.96))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
|
||||||
|
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return fallbackContent
|
||||||
|
}
|
||||||
|
return "Ran \(metadata.toolName ?? "tool")."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ struct SybilSearchResultsView: View {
|
|||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
var isStartingChat: Bool = false
|
var isStartingChat: Bool = false
|
||||||
|
var topContentInset: CGFloat = 0
|
||||||
|
var bottomContentInset: CGFloat = 0
|
||||||
var onStartChat: (() -> Void)? = nil
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -98,7 +100,8 @@ struct SybilSearchResultsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 20)
|
.padding(.top, 20 + topContentInset)
|
||||||
|
.padding(.bottom, 20 + bottomContentInset)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ 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 preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||||
|
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||||
|
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||||
|
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||||
|
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||||
|
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
|
|||||||
var adminToken: String
|
var adminToken: String
|
||||||
var preferredProvider: Provider
|
var preferredProvider: Provider
|
||||||
var preferredModelByProvider: [Provider: String]
|
var preferredModelByProvider: [Provider: String]
|
||||||
|
var quickQuestionPreferredProvider: Provider
|
||||||
|
var quickQuestionPreferredModelByProvider: [Provider: String]
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
|
|||||||
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.preferredProvider = provider
|
self.preferredProvider = provider
|
||||||
|
|
||||||
self.preferredModelByProvider = [
|
let preferredModels: [Provider: String] = [
|
||||||
.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",
|
||||||
|
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||||
|
]
|
||||||
|
self.preferredModelByProvider = preferredModels
|
||||||
|
|
||||||
|
self.quickQuestionPreferredProvider =
|
||||||
|
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||||
|
self.quickQuestionPreferredModelByProvider = [
|
||||||
|
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||||
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
|
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||||
|
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +72,13 @@ 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[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||||
|
|
||||||
|
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trimmedTokenOrNil: String? {
|
var trimmedTokenOrNil: String? {
|
||||||
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
|
|||||||
raw.removeLast()
|
raw.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard var components = URLComponents(string: raw) else {
|
guard let components = URLComponents(string: raw) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import SwiftUI
|
|||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
private func iconName(for item: SidebarItem) -> String {
|
|
||||||
switch item.kind {
|
|
||||||
case .chat: return "message"
|
|
||||||
case .search: return "globe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSelected(_ item: SidebarItem) -> Bool {
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||||
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||||
}
|
}
|
||||||
@@ -57,99 +50,13 @@ struct SybilSidebarView: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: isSelected,
|
||||||
.tint(SybilTheme.primary)
|
onSelect: { item in
|
||||||
Text("Loading conversations…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
Button {
|
|
||||||
viewModel.select(item.selection)
|
viewModel.select(item.selection)
|
||||||
} label: {
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: iconName(for: item))
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -199,3 +106,151 @@ struct SybilSidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarItemList: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var isSelected: (SidebarItem) -> Bool
|
||||||
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Loading conversations…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
Button {
|
||||||
|
onSelect(item)
|
||||||
|
} label: {
|
||||||
|
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshVisibleContent(
|
||||||
|
refreshCollections: true,
|
||||||
|
refreshSelection: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarRow: View {
|
||||||
|
var item: SidebarItem
|
||||||
|
var isSelected: Bool
|
||||||
|
|
||||||
|
private var isHighlighted: Bool {
|
||||||
|
isSelected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch item.kind {
|
||||||
|
case .chat: return "message"
|
||||||
|
case .search: return "globe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if item.isRunning {
|
||||||
|
SybilSidebarActivityIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarActivityIndicator: View {
|
||||||
|
var body: some View {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.accent)
|
||||||
|
.scaleEffect(0.82)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.accessibilityLabel("Generating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let registeredFonts: Void = {
|
private static let registeredFonts: Void = {
|
||||||
for fontName in ["Inter", "Orbitron"] {
|
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
||||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||||
else {
|
else {
|
||||||
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("SYBIL")
|
Text("SYBIL")
|
||||||
.font(.custom("Orbitron", size: size))
|
.font(.custom("Stalinist One", size: size))
|
||||||
.fontWeight(.black)
|
.fontWeight(.black)
|
||||||
.tracking(0)
|
.tracking(0)
|
||||||
.foregroundStyle(SybilTheme.brandGradient)
|
.foregroundStyle(SybilTheme.brandGradient)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,19 @@ import SwiftUI
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
enum SybilWorkspaceNavigationLeadingControl: Equatable {
|
||||||
|
case back
|
||||||
|
case hidden
|
||||||
|
case showSidebar
|
||||||
|
}
|
||||||
|
|
||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var composerFocusRequest: Int = 0
|
var composerFocusRequest: Int = 0
|
||||||
var usesCustomChatNavigation: Bool = false
|
var usesCustomWorkspaceNavigation: Bool = true
|
||||||
|
var navigationLeadingControl: SybilWorkspaceNavigationLeadingControl = .back
|
||||||
|
var onShowSidebar: (() -> Void)? = nil
|
||||||
|
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -17,6 +26,10 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var isShowingPhotoPicker = false
|
@State private var isShowingPhotoPicker = false
|
||||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||||
@State private var isComposerDropTargeted = false
|
@State private var isComposerDropTargeted = false
|
||||||
|
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||||
|
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||||
|
@State private var transcriptViewportHeight: CGFloat = 0
|
||||||
|
@State private var pendingAssistantBaselineHeight: CGFloat?
|
||||||
@State private var newChatSwipeOffset: CGFloat = 0
|
@State private var newChatSwipeOffset: CGFloat = 0
|
||||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||||
@@ -26,7 +39,12 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var newChatSwipeDidTriggerHaptic = false
|
@State private var newChatSwipeDidTriggerHaptic = false
|
||||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||||
|
|
||||||
private let customChatNavigationContentInset: CGFloat = 96
|
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||||
|
private let composerOverlayContentInset: CGFloat = 112
|
||||||
|
|
||||||
|
private var visibleTranscriptTailSpacerHeight: CGFloat {
|
||||||
|
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
|
||||||
|
}
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
@@ -39,8 +57,8 @@ struct SybilWorkspaceView: View {
|
|||||||
viewModel.errorMessage != nil
|
viewModel.errorMessage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsCustomChatNavigation: Bool {
|
private var showsCustomWorkspaceNavigation: Bool {
|
||||||
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
|
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var transcriptScrollContextID: String {
|
private var transcriptScrollContextID: String {
|
||||||
@@ -53,11 +71,19 @@ struct SybilWorkspaceView: View {
|
|||||||
return "chat:none"
|
return "chat:none"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shouldAutoFocusComposer: Bool {
|
||||||
|
viewModel.draftKind == .chat && viewModel.displayedMessages.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composerFocusPolicyID: String {
|
||||||
|
"\(transcriptScrollContextID):\(composerFocusRequest):\(shouldAutoFocusComposer)"
|
||||||
|
}
|
||||||
|
|
||||||
private var canSwipeToCreateChat: Bool {
|
private var canSwipeToCreateChat: Bool {
|
||||||
guard onRequestNewChat != nil else {
|
guard onRequestNewChat != nil else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
guard !viewModel.isActiveSelectionSending, viewModel.draftKind == nil else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard case .chat = viewModel.selectedItem else {
|
guard case .chat = viewModel.selectedItem else {
|
||||||
@@ -74,8 +100,20 @@ struct SybilWorkspaceView: View {
|
|||||||
canSwipeToCreateChat || newChatSwipeIsCompleting
|
canSwipeToCreateChat || newChatSwipeIsCompleting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var workspaceSwipeOffset: CGFloat {
|
||||||
|
newChatSwipeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private var workspaceCompletionOffset: CGFloat {
|
||||||
|
newChatSwipeCompletionOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private var workspaceSwipeBlurRadius: CGFloat {
|
||||||
|
NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .trailing) {
|
ZStack {
|
||||||
if showsNewChatSwipeBackdrop {
|
if showsNewChatSwipeBackdrop {
|
||||||
NewChatSwipeBackdrop(
|
NewChatSwipeBackdrop(
|
||||||
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
||||||
@@ -88,17 +126,17 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
workspaceContent
|
workspaceContent
|
||||||
.compositingGroup()
|
.compositingGroup()
|
||||||
.offset(x: newChatSwipeOffset)
|
.offset(x: workspaceSwipeOffset)
|
||||||
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
|
.blur(radius: workspaceSwipeBlurRadius)
|
||||||
}
|
}
|
||||||
.offset(x: newChatSwipeCompletionOffset)
|
.offset(x: workspaceCompletionOffset)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
|
.navigationTitle(showsCustomWorkspaceNavigation ? "" : viewModel.selectedTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarRole(.editor)
|
.toolbarRole(.editor)
|
||||||
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
|
.toolbar(showsCustomWorkspaceNavigation ? .hidden : .visible, for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !isSettingsSelected && !showsCustomChatNavigation {
|
if !isSettingsSelected && !showsCustomWorkspaceNavigation {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if viewModel.isSearchMode {
|
if viewModel.isSearchMode {
|
||||||
searchModeChip
|
searchModeChip
|
||||||
@@ -115,8 +153,19 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
resetNewChatSwipe(animated: false)
|
resetNewChatSwipe(animated: false)
|
||||||
}
|
}
|
||||||
.task(id: composerFocusRequest) {
|
.onChange(of: transcriptScrollContextID) { _, _ in
|
||||||
await focusComposerIfRequested()
|
handleTranscriptContextChange()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
|
||||||
|
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.errorMessage) { _, message in
|
||||||
|
if message != nil && !viewModel.isSendingVisibleChat {
|
||||||
|
resetTranscriptTailSpacer(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: composerFocusPolicyID) {
|
||||||
|
await applyComposerFocusPolicy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +173,10 @@ struct SybilWorkspaceView: View {
|
|||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
workspaceContentStack
|
workspaceContentStack
|
||||||
|
|
||||||
if showsCustomChatNavigation {
|
if showsCustomWorkspaceNavigation {
|
||||||
SybilChatCharacterBackdrop(isBusy: viewModel.isSending)
|
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
customChatNavigationBar
|
customWorkspaceNavigationBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,15 +190,18 @@ struct SybilWorkspaceView: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
Group {
|
ZStack(alignment: .bottom) {
|
||||||
if isSettingsSelected {
|
if isSettingsSelected {
|
||||||
SybilSettingsView(viewModel: viewModel)
|
SybilSettingsView(viewModel: viewModel)
|
||||||
|
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
|
||||||
} else if viewModel.isSearchMode {
|
} else if viewModel.isSearchMode {
|
||||||
SybilSearchResultsView(
|
SybilSearchResultsView(
|
||||||
search: viewModel.selectedSearch,
|
search: viewModel.displayedSearch,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isRunning: viewModel.isSending,
|
isRunning: viewModel.isRunningVisibleSearch,
|
||||||
isStartingChat: viewModel.isCreatingSearchChat
|
isStartingChat: viewModel.isCreatingSearchChat,
|
||||||
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||||
) {
|
) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.startChatFromSelectedSearch()
|
await viewModel.startChatFromSelectedSearch()
|
||||||
@@ -159,15 +211,28 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilChatTranscriptView(
|
SybilChatTranscriptView(
|
||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
|
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||||
|
onViewportHeightChange: { height in
|
||||||
|
handleTranscriptViewportHeightChange(height)
|
||||||
|
},
|
||||||
|
onPendingAssistantHeightChange: { height in
|
||||||
|
handlePendingAssistantHeightChange(height)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.showsComposer {
|
||||||
|
composerBar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background {
|
.background {
|
||||||
NewChatSwipePanInstaller(
|
WorkspaceSwipePanInstaller(
|
||||||
|
direction: .left,
|
||||||
isEnabled: canRecognizeNewChatSwipe,
|
isEnabled: canRecognizeNewChatSwipe,
|
||||||
onBegan: { width in
|
onBegan: { width in
|
||||||
beginNewChatSwipe(containerWidth: width)
|
beginNewChatSwipe(containerWidth: width)
|
||||||
@@ -175,33 +240,23 @@ struct SybilWorkspaceView: View {
|
|||||||
onChanged: { translationX, width in
|
onChanged: { translationX, width in
|
||||||
updateNewChatSwipe(with: translationX, containerWidth: width)
|
updateNewChatSwipe(with: translationX, containerWidth: width)
|
||||||
},
|
},
|
||||||
onEnded: { translationX, width, didFinish in
|
onEnded: { translationX, width, velocityX, didFinish in
|
||||||
finishNewChatSwipe(
|
finishNewChatSwipe(
|
||||||
translationX: translationX,
|
translationX: translationX,
|
||||||
containerWidth: width,
|
containerWidth: width,
|
||||||
|
velocityX: velocityX,
|
||||||
didFinish: didFinish
|
didFinish: didFinish
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
if viewModel.showsComposer {
|
|
||||||
Divider()
|
|
||||||
.overlay(SybilTheme.border)
|
|
||||||
composerBar
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var customChatNavigationBar: some View {
|
private var customWorkspaceNavigationBar: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Button {
|
workspaceNavigationLeadingControl
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
SybilNavigationIcon(systemImage: "chevron.left")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Back")
|
|
||||||
|
|
||||||
Text(viewModel.selectedTitle)
|
Text(viewModel.selectedTitle)
|
||||||
.font(.sybil(size: 16, weight: .semibold))
|
.font(.sybil(size: 16, weight: .semibold))
|
||||||
@@ -211,7 +266,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
providerModelNavigationMenu
|
workspaceNavigationTrailingControl
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
@@ -222,6 +277,120 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var workspaceNavigationLeadingControl: some View {
|
||||||
|
switch navigationLeadingControl {
|
||||||
|
case .back:
|
||||||
|
Button {
|
||||||
|
requestBack()
|
||||||
|
} label: {
|
||||||
|
SybilNavigationIcon(systemImage: "chevron.left")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Back")
|
||||||
|
|
||||||
|
case .showSidebar:
|
||||||
|
Button {
|
||||||
|
onShowSidebar?()
|
||||||
|
} label: {
|
||||||
|
SybilNavigationIcon(systemImage: "sidebar.left")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Show sidebar")
|
||||||
|
|
||||||
|
case .hidden:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestBack(animateNavigation: Bool = true) {
|
||||||
|
if let onRequestBack {
|
||||||
|
onRequestBack(animateNavigation)
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTranscriptContextChange() {
|
||||||
|
resetTranscriptTailSpacer(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
|
||||||
|
guard !viewModel.isSearchMode else {
|
||||||
|
resetTranscriptTailSpacer(animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSending {
|
||||||
|
prepareTranscriptTailSpacerForReply(animated: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if wasSending {
|
||||||
|
if viewModel.errorMessage != nil {
|
||||||
|
resetTranscriptTailSpacer(animated: true)
|
||||||
|
}
|
||||||
|
pendingAssistantBaselineHeight = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
|
||||||
|
transcriptViewportHeight = height
|
||||||
|
|
||||||
|
if viewModel.isSendingVisibleChat,
|
||||||
|
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
|
||||||
|
prepareTranscriptTailSpacerForReply(animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
|
||||||
|
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pendingAssistantBaselineHeight == nil {
|
||||||
|
pendingAssistantBaselineHeight = height
|
||||||
|
}
|
||||||
|
|
||||||
|
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
|
||||||
|
targetHeight: transcriptTailSpacerTargetHeight,
|
||||||
|
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
|
||||||
|
currentAssistantHeight: height
|
||||||
|
)
|
||||||
|
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
|
||||||
|
setTranscriptTailSpacer(nextHeight, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
|
||||||
|
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
|
||||||
|
transcriptTailSpacerTargetHeight = targetHeight
|
||||||
|
pendingAssistantBaselineHeight = nil
|
||||||
|
setTranscriptTailSpacer(targetHeight, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetTranscriptTailSpacer(animated: Bool) {
|
||||||
|
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||||
|
pendingAssistantBaselineHeight = nil
|
||||||
|
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
|
||||||
|
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
|
||||||
|
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let update = {
|
||||||
|
transcriptTailSpacerHeight = nextHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.22), update)
|
||||||
|
} else {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
let update = {
|
let update = {
|
||||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
@@ -263,30 +432,56 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) {
|
private func finishNewChatSwipe(
|
||||||
|
translationX: CGFloat,
|
||||||
|
containerWidth: CGFloat,
|
||||||
|
velocityX: CGFloat,
|
||||||
|
didFinish: Bool
|
||||||
|
) {
|
||||||
guard newChatSwipeIsActive else {
|
guard newChatSwipeIsActive else {
|
||||||
resetNewChatSwipe(animated: false)
|
resetNewChatSwipe(animated: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalOffset = NewChatSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||||
|
let finalLatched = NewChatSwipeMetrics.isLatched(
|
||||||
|
offset: finalOffset,
|
||||||
|
width: containerWidth,
|
||||||
|
isCurrentlyLatched: newChatSwipeHasLatched
|
||||||
|
)
|
||||||
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
||||||
|
|
||||||
if didFinish && newChatSwipeHasLatched {
|
if didFinish && NewChatSwipeMetrics.shouldComplete(
|
||||||
|
offset: finalOffset,
|
||||||
|
velocityX: velocityX,
|
||||||
|
width: containerWidth,
|
||||||
|
isLatched: finalLatched
|
||||||
|
) {
|
||||||
Task {
|
Task {
|
||||||
await completeNewChatSwipe(containerWidth: containerWidth)
|
await completeNewChatSwipe(
|
||||||
|
containerWidth: containerWidth,
|
||||||
|
releaseVelocityX: velocityX
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resetNewChatSwipe(animated: true)
|
resetNewChatSwipe(animated: true, velocityX: velocityX)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func completeNewChatSwipe(containerWidth: CGFloat) async {
|
private func completeNewChatSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
|
||||||
newChatSwipeIsCompleting = true
|
newChatSwipeIsCompleting = true
|
||||||
|
let targetOffset = NewChatSwipeMetrics.completionTargetOffset(for: containerWidth)
|
||||||
|
|
||||||
withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) {
|
withAnimation(
|
||||||
newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot)
|
NewChatSwipeMetrics.springAnimation(
|
||||||
|
currentOffset: newChatSwipeOffset,
|
||||||
|
targetOffset: targetOffset,
|
||||||
|
velocityX: releaseVelocityX
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
newChatSwipeCompletionOffset = targetOffset - newChatSwipeOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
|
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
|
||||||
@@ -294,7 +489,8 @@ struct SybilWorkspaceView: View {
|
|||||||
resetNewChatSwipe(animated: false)
|
resetNewChatSwipe(animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetNewChatSwipe(animated: Bool) {
|
private func resetNewChatSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||||
|
let currentOffset = newChatSwipeOffset + newChatSwipeCompletionOffset
|
||||||
let reset = {
|
let reset = {
|
||||||
newChatSwipeOffset = 0
|
newChatSwipeOffset = 0
|
||||||
newChatSwipeCompletionOffset = 0
|
newChatSwipeCompletionOffset = 0
|
||||||
@@ -305,7 +501,13 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
withAnimation(
|
||||||
|
NewChatSwipeMetrics.springAnimation(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: 0,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
) {
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -316,15 +518,13 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func focusComposerIfRequested() async {
|
private func applyComposerFocusPolicy() async {
|
||||||
guard composerFocusRequest > 0 else {
|
guard shouldAutoFocusComposer else {
|
||||||
|
composerFocused = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.yield()
|
guard shouldAutoFocusComposer, viewModel.showsComposer else {
|
||||||
try? await Task.sleep(for: .milliseconds(80))
|
|
||||||
|
|
||||||
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
composerFocused = true
|
composerFocused = true
|
||||||
@@ -367,6 +567,24 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var workspaceNavigationTrailingControl: some View {
|
||||||
|
if isSettingsSelected {
|
||||||
|
EmptyView()
|
||||||
|
} else if viewModel.isSearchMode {
|
||||||
|
searchModeNavigationLabel
|
||||||
|
} else {
|
||||||
|
providerModelNavigationMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchModeNavigationLabel: some View {
|
||||||
|
Label("Search", systemImage: "globe")
|
||||||
|
.font(.sybil(.caption, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.accent)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
||||||
Menu {
|
Menu {
|
||||||
providerModelMenuItems
|
providerModelMenuItems
|
||||||
@@ -383,7 +601,7 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
ForEach(viewModel.providerOptions, id: \.self) { candidate in
|
||||||
Menu(candidate.displayName) {
|
Menu(candidate.displayName) {
|
||||||
let models = viewModel.modelOptions(for: candidate)
|
let models = viewModel.modelOptions(for: candidate)
|
||||||
if models.isEmpty {
|
if models.isEmpty {
|
||||||
@@ -448,15 +666,15 @@ struct SybilWorkspaceView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||||
)
|
)
|
||||||
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(viewModel.isSending)
|
.disabled(viewModel.isActiveSelectionSending)
|
||||||
.accessibilityLabel("Attach file")
|
.accessibilityLabel("Attach file")
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||||
text: $viewModel.composer,
|
text: $viewModel.composer,
|
||||||
axis: .vertical
|
axis: .vertical
|
||||||
)
|
)
|
||||||
@@ -473,10 +691,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(SybilTheme.composerGradient)
|
.fill(SybilTheme.composerGradient)
|
||||||
.overlay(
|
.opacity(0.98)
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
@@ -501,27 +716,23 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.top, 64)
|
||||||
.background(
|
.padding(.bottom, 12)
|
||||||
LinearGradient(
|
.background(alignment: .bottom) {
|
||||||
colors: [
|
SybilComposerFadeBackground()
|
||||||
SybilTheme.background.opacity(0.18),
|
.allowsHitTesting(false)
|
||||||
SybilTheme.background.opacity(0.96)
|
}
|
||||||
],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if isComposerDropTargeted && !viewModel.isSearchMode {
|
if isComposerDropTargeted && !viewModel.isSearchMode {
|
||||||
RoundedRectangle(cornerRadius: 18)
|
RoundedRectangle(cornerRadius: 18)
|
||||||
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.top, 32)
|
||||||
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
||||||
if viewModel.isSearchMode || viewModel.isSending {
|
if viewModel.isSearchMode || viewModel.isActiveSelectionSending {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,6 +808,10 @@ struct SybilWorkspaceView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !viewModel.isSearchMode {
|
||||||
|
prepareTranscriptTailSpacerForReply(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
if !viewModel.isSearchMode {
|
if !viewModel.isSearchMode {
|
||||||
composerFocused = false
|
composerFocused = false
|
||||||
@@ -666,15 +881,45 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SybilTranscriptTailSpacer {
|
||||||
|
static let minimumHeight: CGFloat = 20
|
||||||
|
static let replyBufferMin: CGFloat = 288
|
||||||
|
static let replyBufferMax: CGFloat = 576
|
||||||
|
static let replyBufferViewportRatio: CGFloat = 0.52
|
||||||
|
|
||||||
|
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
|
||||||
|
guard viewportHeight > 0 else {
|
||||||
|
return replyBufferMin
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(
|
||||||
|
replyBufferMax,
|
||||||
|
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clampedHeight(_ height: CGFloat) -> CGFloat {
|
||||||
|
max(minimumHeight, height.rounded(.up))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func placeholderHeight(
|
||||||
|
targetHeight: CGFloat,
|
||||||
|
baselineAssistantHeight: CGFloat,
|
||||||
|
currentAssistantHeight: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
|
||||||
|
return clampedHeight(targetHeight - consumedHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum NewChatSwipeMetrics {
|
enum NewChatSwipeMetrics {
|
||||||
static let referenceWidth: CGFloat = 390
|
static let referenceWidth: CGFloat = 390
|
||||||
static let horizontalActivationDistance: CGFloat = 18
|
static let horizontalActivationDistance: CGFloat = 18
|
||||||
static let directionDominanceRatio: CGFloat = 1.22
|
static let directionDominanceRatio: CGFloat = 1.22
|
||||||
static let minimumLeftwardVelocity: CGFloat = 55
|
static let minimumLeftwardVelocity: CGFloat = 55
|
||||||
static let latchHysteresis: CGFloat = 32
|
static let latchHysteresis: CGFloat = 32
|
||||||
static let completionOvershoot: CGFloat = 180
|
static let completionOvershoot = WorkspaceSwipePhysics.completionOvershoot
|
||||||
static let completionAnimationDuration = 0.24
|
static let completionAnimationDelayMs = WorkspaceSwipePhysics.completionAnimationDelayMs
|
||||||
static let completionAnimationDelayMs: UInt64 = 240
|
|
||||||
|
|
||||||
static func maxTravel(for width: CGFloat) -> CGFloat {
|
static func maxTravel(for width: CGFloat) -> CGFloat {
|
||||||
min(max(width * 0.46, 156), 240)
|
min(max(width * 0.46, 156), 240)
|
||||||
@@ -730,13 +975,185 @@ enum NewChatSwipeMetrics {
|
|||||||
}
|
}
|
||||||
return distance >= latchDistance(for: width)
|
return distance >= latchDistance(for: width)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||||
|
WorkspaceSwipePhysics.shouldComplete(
|
||||||
|
offset: offset,
|
||||||
|
velocityX: velocityX,
|
||||||
|
width: width,
|
||||||
|
directionSign: -1,
|
||||||
|
isLatched: isLatched,
|
||||||
|
latchDistance: latchDistance(for: width)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
static func completionTargetOffset(for width: CGFloat) -> CGFloat {
|
||||||
|
WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
|
WorkspaceSwipePhysics.springAnimation(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: targetOffset,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackSwipeMetrics {
|
||||||
|
static let referenceWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||||
|
static let horizontalActivationDistance: CGFloat = NewChatSwipeMetrics.horizontalActivationDistance
|
||||||
|
static let directionDominanceRatio: CGFloat = NewChatSwipeMetrics.directionDominanceRatio
|
||||||
|
static let minimumRightwardVelocity: CGFloat = NewChatSwipeMetrics.minimumLeftwardVelocity
|
||||||
|
static let latchHysteresis: CGFloat = NewChatSwipeMetrics.latchHysteresis
|
||||||
|
static let completionOvershoot: CGFloat = NewChatSwipeMetrics.completionOvershoot
|
||||||
|
static let completionAnimationDelayMs = NewChatSwipeMetrics.completionAnimationDelayMs
|
||||||
|
|
||||||
|
static func maxTravel(for width: CGFloat) -> CGFloat {
|
||||||
|
NewChatSwipeMetrics.maxTravel(for: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func latchDistance(for width: CGFloat) -> CGFloat {
|
||||||
|
NewChatSwipeMetrics.latchDistance(for: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
min(max(rawTranslation, 0), maxTravel(for: width))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
NewChatSwipeMetrics.progress(for: offset, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
NewChatSwipeMetrics.blurRadius(for: offset, width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldBeginPan(
|
||||||
|
rightwardTravel: CGFloat,
|
||||||
|
verticalTravel: CGFloat,
|
||||||
|
rightwardVelocity: CGFloat,
|
||||||
|
verticalVelocity: CGFloat
|
||||||
|
) -> Bool {
|
||||||
|
guard rightwardTravel > 0 || rightwardVelocity > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rightwardTravel >= horizontalActivationDistance,
|
||||||
|
rightwardTravel >= verticalTravel * directionDominanceRatio {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return rightwardVelocity >= minimumRightwardVelocity &&
|
||||||
|
rightwardVelocity >= verticalVelocity * directionDominanceRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
|
||||||
|
NewChatSwipeMetrics.latchReleaseDistance(for: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||||
|
NewChatSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||||
|
WorkspaceSwipePhysics.shouldComplete(
|
||||||
|
offset: offset,
|
||||||
|
velocityX: velocityX,
|
||||||
|
width: width,
|
||||||
|
directionSign: 1,
|
||||||
|
isLatched: isLatched,
|
||||||
|
latchDistance: latchDistance(for: width)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func completionTargetOffset(for width: CGFloat) -> CGFloat {
|
||||||
|
WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
|
WorkspaceSwipePhysics.springAnimation(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: targetOffset,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkspaceSwipePhysics {
|
||||||
|
static let velocityProjectionDuration: CGFloat = 0.18
|
||||||
|
static let completionVelocityThreshold: CGFloat = 620
|
||||||
|
static let completionOvershoot: CGFloat = 180
|
||||||
|
static let completionAnimationDelayMs: UInt64 = 320
|
||||||
|
|
||||||
|
private static let springMass: Double = 1
|
||||||
|
private static let springStiffness: Double = 300
|
||||||
|
private static let springDamping: Double = 34
|
||||||
|
private static let maximumInitialVelocity: CGFloat = 10
|
||||||
|
|
||||||
|
static func shouldComplete(
|
||||||
|
offset: CGFloat,
|
||||||
|
velocityX: CGFloat,
|
||||||
|
width: CGFloat,
|
||||||
|
directionSign: CGFloat,
|
||||||
|
isLatched: Bool,
|
||||||
|
latchDistance: CGFloat
|
||||||
|
) -> Bool {
|
||||||
|
let directionalOffset = offset * directionSign
|
||||||
|
let directionalVelocity = velocityX * directionSign
|
||||||
|
|
||||||
|
if directionalVelocity <= -completionVelocityThreshold {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if directionalVelocity >= completionVelocityThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectedOffset = directionalOffset + directionalVelocity * velocityProjectionDuration
|
||||||
|
return isLatched || projectedOffset >= latchDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
static func completionTargetOffset(for width: CGFloat, directionSign: CGFloat) -> CGFloat {
|
||||||
|
directionSign * (max(width, 1) + completionOvershoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
|
.interpolatingSpring(
|
||||||
|
mass: springMass,
|
||||||
|
stiffness: springStiffness,
|
||||||
|
damping: springDamping,
|
||||||
|
initialVelocity: springInitialVelocity(
|
||||||
|
currentOffset: currentOffset,
|
||||||
|
targetOffset: targetOffset,
|
||||||
|
velocityX: velocityX
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func springInitialVelocity(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Double {
|
||||||
|
let distance = targetOffset - currentOffset
|
||||||
|
guard abs(distance) > 1 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedVelocity = velocityX / distance
|
||||||
|
let clampedVelocity = min(max(normalizedVelocity, -maximumInitialVelocity), maximumInitialVelocity)
|
||||||
|
return Double(clampedVelocity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkspaceSwipeDirection {
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceSwipePanInstaller: UIViewRepresentable {
|
||||||
|
var direction: WorkspaceSwipeDirection
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
var onBegan: (CGFloat) -> Void
|
var onBegan: (CGFloat) -> Void
|
||||||
var onChanged: (CGFloat, CGFloat) -> Void
|
var onChanged: (CGFloat, CGFloat) -> Void
|
||||||
var onEnded: (CGFloat, CGFloat, Bool) -> Void
|
var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator()
|
Coordinator()
|
||||||
@@ -752,6 +1169,7 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
|
|
||||||
func updateUIView(_ uiView: InstallerView, context: Context) {
|
func updateUIView(_ uiView: InstallerView, context: Context) {
|
||||||
context.coordinator.update(
|
context.coordinator.update(
|
||||||
|
direction: direction,
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
onBegan: onBegan,
|
onBegan: onBegan,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
@@ -786,10 +1204,11 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
private let panGesture = UIPanGestureRecognizer()
|
private let panGesture = UIPanGestureRecognizer()
|
||||||
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
||||||
|
|
||||||
|
private var direction: WorkspaceSwipeDirection = .left
|
||||||
private var isEnabled = false
|
private var isEnabled = false
|
||||||
private var onBegan: (CGFloat) -> Void = { _ in }
|
private var onBegan: (CGFloat) -> Void = { _ in }
|
||||||
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
||||||
private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in }
|
private var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void = { _, _, _, _ in }
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -801,11 +1220,13 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func update(
|
func update(
|
||||||
|
direction: WorkspaceSwipeDirection,
|
||||||
isEnabled: Bool,
|
isEnabled: Bool,
|
||||||
onBegan: @escaping (CGFloat) -> Void,
|
onBegan: @escaping (CGFloat) -> Void,
|
||||||
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
||||||
onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void
|
onEnded: @escaping (CGFloat, CGFloat, CGFloat, Bool) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.direction = direction
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
self.onBegan = onBegan
|
self.onBegan = onBegan
|
||||||
self.onChanged = onChanged
|
self.onChanged = onChanged
|
||||||
@@ -864,6 +1285,7 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
|
|
||||||
let width = max(markerView.bounds.width, 1)
|
let width = max(markerView.bounds.width, 1)
|
||||||
let translationX = recognizer.translation(in: markerView).x
|
let translationX = recognizer.translation(in: markerView).x
|
||||||
|
let velocityX = recognizer.velocity(in: markerView).x
|
||||||
|
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
case .began:
|
||||||
@@ -874,16 +1296,16 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
onChanged(translationX, width)
|
onChanged(translationX, width)
|
||||||
|
|
||||||
case .ended:
|
case .ended:
|
||||||
onEnded(translationX, width, true)
|
onEnded(translationX, width, velocityX, true)
|
||||||
|
|
||||||
case .cancelled, .failed:
|
case .cancelled, .failed:
|
||||||
onEnded(translationX, width, false)
|
onEnded(translationX, width, velocityX, false)
|
||||||
|
|
||||||
case .possible:
|
case .possible:
|
||||||
break
|
break
|
||||||
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
onEnded(translationX, width, false)
|
onEnded(translationX, width, velocityX, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,12 +1324,22 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|||||||
|
|
||||||
let translation = panGesture.translation(in: markerView)
|
let translation = panGesture.translation(in: markerView)
|
||||||
let velocity = panGesture.velocity(in: markerView)
|
let velocity = panGesture.velocity(in: markerView)
|
||||||
|
switch direction {
|
||||||
|
case .left:
|
||||||
return NewChatSwipeMetrics.shouldBeginPan(
|
return NewChatSwipeMetrics.shouldBeginPan(
|
||||||
leftwardTravel: max(-translation.x, 0),
|
leftwardTravel: max(-translation.x, 0),
|
||||||
verticalTravel: abs(translation.y),
|
verticalTravel: abs(translation.y),
|
||||||
leftwardVelocity: max(-velocity.x, 0),
|
leftwardVelocity: max(-velocity.x, 0),
|
||||||
verticalVelocity: abs(velocity.y)
|
verticalVelocity: abs(velocity.y)
|
||||||
)
|
)
|
||||||
|
case .right:
|
||||||
|
return BackSwipeMetrics.shouldBeginPan(
|
||||||
|
rightwardTravel: max(translation.x, 0),
|
||||||
|
verticalTravel: abs(translation.y),
|
||||||
|
rightwardVelocity: max(velocity.x, 0),
|
||||||
|
verticalVelocity: abs(velocity.y)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func gestureRecognizer(
|
func gestureRecognizer(
|
||||||
@@ -937,6 +1369,60 @@ private extension UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SybilComposerFadeBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.clear,
|
||||||
|
SybilTheme.background.opacity(0.30),
|
||||||
|
SybilTheme.background.opacity(0.86),
|
||||||
|
SybilTheme.background.opacity(0.86),
|
||||||
|
SybilTheme.background.opacity(0.98)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
SybilTheme.primary.opacity(0.18),
|
||||||
|
SybilTheme.surface.opacity(0.16),
|
||||||
|
SybilTheme.accent.opacity(0.08)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.mask(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.clear,
|
||||||
|
Color.black.opacity(0.42),
|
||||||
|
Color.black
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.blendMode(.screen)
|
||||||
|
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
SybilTheme.primary.opacity(0.28),
|
||||||
|
SybilTheme.primary.opacity(0.08),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .bottomLeading,
|
||||||
|
startRadius: 8,
|
||||||
|
endRadius: 80
|
||||||
|
)
|
||||||
|
.blendMode(.screen)
|
||||||
|
.offset(y: 42)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SybilNavigationIcon: View {
|
private struct SybilNavigationIcon: View {
|
||||||
var systemImage: String
|
var systemImage: String
|
||||||
|
|
||||||
@@ -977,13 +1463,14 @@ private struct SybilNavigationFadeBackground: View {
|
|||||||
endRadius: 210
|
endRadius: 210
|
||||||
)
|
)
|
||||||
.blendMode(.screen)
|
.blendMode(.screen)
|
||||||
.offset(x: -44, y: -46)
|
.offset(y: -46)
|
||||||
}
|
}
|
||||||
|
.frame(height: 200.0)
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilChatCharacterBackdrop: View {
|
private struct SybilWorkspaceCharacterBackdrop: View {
|
||||||
var isBusy: Bool
|
var isBusy: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -1207,15 +1694,10 @@ private struct NewChatSwipeBackdrop: View {
|
|||||||
.frame(width: 72, height: 72)
|
.frame(width: 72, height: 72)
|
||||||
.blur(radius: 10)
|
.blur(radius: 10)
|
||||||
|
|
||||||
Image(systemName: hasLatched ? "checkmark" : "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 31, weight: .bold, design: .rounded))
|
.font(.system(size: 31, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.symbolEffect(.bounce, value: hasLatched)
|
.symbolEffect(.bounce, value: hasLatched)
|
||||||
|
|
||||||
Image(systemName: "sparkle")
|
|
||||||
.font(.system(size: 11, weight: .semibold))
|
|
||||||
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
|
||||||
.offset(x: -26, y: -25)
|
|
||||||
}
|
}
|
||||||
.frame(width: 92, height: 92)
|
.frame(width: 92, height: 92)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -6,8 +6,20 @@ import Testing
|
|||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
var listChats = 0
|
var listChats = 0
|
||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
|
var getActiveRuns = 0
|
||||||
|
var runCompletionStream = 0
|
||||||
|
var attachCompletionStream = 0
|
||||||
|
var attachSearchStream = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatCreateCallSnapshot: Sendable {
|
||||||
|
var title: String?
|
||||||
|
var provider: Provider?
|
||||||
|
var model: String?
|
||||||
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct UnexpectedClientCall: Error {}
|
private struct UnexpectedClientCall: Error {}
|
||||||
@@ -17,39 +29,93 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
|
private let createChatResponse: ChatSummary?
|
||||||
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
|
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||||
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
|
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||||
|
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
private var searchStreamNetworkErrorMessage: String?
|
||||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||||
|
private var searchAttachDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
searchesResponse: [SearchSummary] = [],
|
searchesResponse: [SearchSummary] = [],
|
||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:]
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
|
self.createChatResponse = createChatResponse
|
||||||
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentCreateChatCall() -> ChatCreateCallSnapshot? {
|
||||||
|
lastCreateChatCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentCompletionStreamBody() -> CompletionStreamRequest? {
|
||||||
|
lastCompletionStreamBody
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
||||||
|
completionStreamEvents = events
|
||||||
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
completionStreamNetworkErrorMessage = message
|
completionStreamNetworkErrorMessage = message
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||||
|
getChatDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||||
|
getSearchDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
searchStreamNetworkErrorMessage = message
|
searchStreamNetworkErrorMessage = message
|
||||||
searchStreamDelayNanoseconds = delayNanoseconds
|
searchStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCompletionAttachEvents(
|
||||||
|
chatID: String,
|
||||||
|
events: [CompletionStreamEvent],
|
||||||
|
delayNanoseconds: UInt64 = 0
|
||||||
|
) {
|
||||||
|
completionAttachEvents[chatID] = events
|
||||||
|
completionAttachDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSearchAttachEvents(
|
||||||
|
searchID: String,
|
||||||
|
events: [SearchStreamEvent],
|
||||||
|
delayNanoseconds: UInt64 = 0
|
||||||
|
) {
|
||||||
|
searchAttachEvents[searchID] = events
|
||||||
|
searchAttachDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func verifySession() async throws -> AuthSession {
|
func verifySession() async throws -> AuthSession {
|
||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
@@ -59,12 +125,30 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return chatsResponse
|
return chatsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String?) async throws -> ChatSummary {
|
func createChat(
|
||||||
|
title: String?,
|
||||||
|
provider: Provider?,
|
||||||
|
model: String?,
|
||||||
|
messages: [CompletionRequestMessage]?
|
||||||
|
) async throws -> ChatSummary {
|
||||||
|
snapshot.createChat += 1
|
||||||
|
lastCreateChatCall = ChatCreateCallSnapshot(
|
||||||
|
title: title,
|
||||||
|
provider: provider,
|
||||||
|
model: model,
|
||||||
|
messages: messages
|
||||||
|
)
|
||||||
|
if let createChatResponse {
|
||||||
|
return createChatResponse
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChat(chatID: String) async throws -> ChatDetail {
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
snapshot.getChat += 1
|
snapshot.getChat += 1
|
||||||
|
if getChatDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||||
|
}
|
||||||
guard let detail = chatDetails[chatID] else {
|
guard let detail = chatDetails[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -90,6 +174,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
snapshot.getSearch += 1
|
snapshot.getSearch += 1
|
||||||
|
if getSearchDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
||||||
|
}
|
||||||
guard let detail = searchDetails[searchID] else {
|
guard let detail = searchDetails[searchID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -108,19 +195,46 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
ModelCatalogResponse(providers: [:])
|
ModelCatalogResponse(providers: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
|
snapshot.getActiveRuns += 1
|
||||||
|
return activeRunsResponse
|
||||||
|
}
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
|
snapshot.runCompletionStream += 1
|
||||||
|
lastCompletionStreamBody = body
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
}
|
}
|
||||||
if let completionStreamNetworkErrorMessage {
|
if let completionStreamNetworkErrorMessage {
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||||
}
|
}
|
||||||
|
if let completionStreamEvents {
|
||||||
|
for event in completionStreamEvents {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
snapshot.attachCompletionStream += 1
|
||||||
|
let events = completionAttachEvents[chatID] ?? []
|
||||||
|
for event in events {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
if completionAttachDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -134,6 +248,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
snapshot.attachSearchStream += 1
|
||||||
|
let events = searchAttachEvents[searchID] ?? []
|
||||||
|
for event in events {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
if searchAttachDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -293,6 +421,264 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_210)
|
||||||
|
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
|
||||||
|
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
|
||||||
|
await client.setGetChatDelay(50_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-old")
|
||||||
|
viewModel.selectedChat = staleDetail
|
||||||
|
|
||||||
|
viewModel.select(.chat("chat-new"))
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(viewModel.isLoadingSelection)
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 90_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_220)
|
||||||
|
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
|
||||||
|
await client.setGetChatDelay(20_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
|
||||||
|
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-fast"))
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_230)
|
||||||
|
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
|
||||||
|
await client.setGetChatDelay(100_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
|
||||||
|
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-slow"))
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(viewModel.isLoadingSelection)
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
||||||
|
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
|
||||||
|
await client.setCompletionStreamNetworkError(
|
||||||
|
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||||
|
delayNanoseconds: 50_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-typing")
|
||||||
|
viewModel.selectedChat = detail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let sendTask = Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.isSendingVisibleChat)
|
||||||
|
|
||||||
|
viewModel.startNewChat()
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(!viewModel.isSendingVisibleChat)
|
||||||
|
|
||||||
|
await sendTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
await client.setCompletionStreamEvents([
|
||||||
|
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
||||||
|
.done(CompletionStreamDone(text: "Reset it from Settings."))
|
||||||
|
])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.quickQuestionPrompt = "How do I reset my password?"
|
||||||
|
|
||||||
|
let task = viewModel.sendQuickQuestion()
|
||||||
|
await task?.value
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
let body = await client.currentCompletionStreamBody()
|
||||||
|
#expect(snapshot.runCompletionStream == 1)
|
||||||
|
#expect(body?.persist == false)
|
||||||
|
#expect(body?.chatId == nil)
|
||||||
|
#expect(body?.provider == .openai)
|
||||||
|
#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.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_250)
|
||||||
|
let chat = makeChatSummary(id: "quick-chat", date: date)
|
||||||
|
let detail = ChatDetail(
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title,
|
||||||
|
createdAt: chat.createdAt,
|
||||||
|
updatedAt: chat.updatedAt,
|
||||||
|
initiatedProvider: .openai,
|
||||||
|
initiatedModel: "gpt-4.1-mini",
|
||||||
|
lastUsedProvider: .openai,
|
||||||
|
lastUsedModel: "gpt-4.1-mini",
|
||||||
|
messages: [
|
||||||
|
Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
|
||||||
|
Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: [chat.id: detail],
|
||||||
|
createChatResponse: chat
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
|
||||||
|
viewModel.quickQuestionSubmittedProvider = .openai
|
||||||
|
viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
|
||||||
|
viewModel.quickQuestionMessages = [
|
||||||
|
Message(
|
||||||
|
id: "temp-assistant-quick",
|
||||||
|
createdAt: date,
|
||||||
|
role: .assistant,
|
||||||
|
content: "Reset it from Settings.",
|
||||||
|
name: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
let didConvert = await viewModel.convertQuickQuestionToChat()
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
let createCall = await client.currentCreateChatCall()
|
||||||
|
#expect(didConvert)
|
||||||
|
#expect(snapshot.createChat == 1)
|
||||||
|
#expect(createCall?.title == "How do I reset my password?")
|
||||||
|
#expect(createCall?.provider == .openai)
|
||||||
|
#expect(createCall?.model == "gpt-4.1-mini")
|
||||||
|
#expect(createCall?.messages?.map(\.role) == [.user, .assistant])
|
||||||
|
#expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
|
||||||
|
#expect(viewModel.selectedItem == .chat("quick-chat"))
|
||||||
|
#expect(viewModel.quickQuestionPrompt.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
let settings = SybilSettingsStore(defaults: defaults)
|
||||||
|
settings.apiBaseURL = "http://127.0.0.1:8787"
|
||||||
|
let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
|
||||||
|
viewModel.modelCatalog = [
|
||||||
|
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
|
||||||
|
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
|
||||||
|
]
|
||||||
|
|
||||||
|
viewModel.setQuickQuestionProvider(.anthropic)
|
||||||
|
viewModel.setQuickQuestionModel("claude-3-haiku")
|
||||||
|
|
||||||
|
#expect(viewModel.quickQuestionProvider == .anthropic)
|
||||||
|
#expect(viewModel.quickQuestionModel == "claude-3-haiku")
|
||||||
|
#expect(settings.preferredProvider == .openai)
|
||||||
|
|
||||||
|
let reloadedSettings = SybilSettingsStore(defaults: defaults)
|
||||||
|
#expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
|
||||||
|
#expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
|
||||||
|
#expect(reloadedSettings.preferredProvider == .openai)
|
||||||
|
|
||||||
|
let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
|
||||||
|
#expect(reloadedViewModel.quickQuestionProvider == .anthropic)
|
||||||
|
#expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
|
||||||
|
#expect(reloadedViewModel.provider == .openai)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||||
|
let chat = makeChatSummary(id: "chat-active", date: date)
|
||||||
|
let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-active": detail],
|
||||||
|
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||||
|
)
|
||||||
|
await client.setCompletionAttachEvents(
|
||||||
|
chatID: "chat-active",
|
||||||
|
events: [.delta(CompletionStreamDelta(text: "streaming"))],
|
||||||
|
delayNanoseconds: 100_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
|
||||||
|
await viewModel.reconnect()
|
||||||
|
try await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.getActiveRuns >= 1)
|
||||||
|
#expect(snapshot.attachCompletionStream == 1)
|
||||||
|
#expect(viewModel.sidebarItems.first?.isRunning == true)
|
||||||
|
#expect(viewModel.isSendingVisibleChat)
|
||||||
|
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||||
|
let activeChat = makeChatSummary(id: "chat-active", date: date)
|
||||||
|
let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [idleChat, activeChat],
|
||||||
|
chatDetails: [
|
||||||
|
"chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
|
||||||
|
"chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
|
||||||
|
],
|
||||||
|
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.selectedItem = .chat("chat-idle")
|
||||||
|
viewModel.composer = "new message"
|
||||||
|
|
||||||
|
await viewModel.reconnect()
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-idle"))
|
||||||
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
|
||||||
|
#expect(!viewModel.isActiveSelectionSending)
|
||||||
|
#expect(viewModel.canSendComposer)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
||||||
@@ -387,4 +773,34 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||||
|
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
|
||||||
|
#expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
|
||||||
|
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
|
||||||
|
#expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
|
||||||
|
#expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
|
||||||
|
#expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
|
||||||
|
#expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
|
||||||
|
#expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
|
||||||
|
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||||
|
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
|
||||||
|
let targetHeight: CGFloat = 320
|
||||||
|
let baselineAssistantHeight: CGFloat = 28
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
SybilTranscriptTailSpacer.placeholderHeight(
|
||||||
|
targetHeight: targetHeight,
|
||||||
|
baselineAssistantHeight: baselineAssistantHeight,
|
||||||
|
currentAssistantHeight: baselineAssistantHeight + 120
|
||||||
|
) == 200
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
SybilTranscriptTailSpacer.placeholderHeight(
|
||||||
|
targetHeight: targetHeight,
|
||||||
|
baselineAssistantHeight: baselineAssistantHeight,
|
||||||
|
currentAssistantHeight: baselineAssistantHeight + 500
|
||||||
|
) == SybilTranscriptTailSpacer.minimumHeight
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
22
ios/justfile
22
ios/justfile
@@ -1,10 +1,28 @@
|
|||||||
|
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
||||||
|
simulator_name := "iPhone 16e"
|
||||||
|
derived_data := "build/DerivedData"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||||
|
|
||||||
|
run:
|
||||||
|
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||||
|
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||||
|
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||||
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
screenshot path="build/sybil-screenshot.png":
|
||||||
|
mkdir -p "$(dirname '{{path}}')"
|
||||||
|
xcrun simctl io booted screenshot '{{path}}'
|
||||||
|
|||||||
BIN
original_assets/character-busy.mp4
Normal file
BIN
original_assets/character-busy.mp4
Normal file
Binary file not shown.
BIN
original_assets/character-idle.mp4
Normal file
BIN
original_assets/character-idle.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok)
|
- 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,6 +43,9 @@ 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`
|
||||||
|
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||||
|
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||||
|
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||||
- `EXA_API_KEY`
|
- `EXA_API_KEY`
|
||||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MessageRole {
|
enum MessageRole {
|
||||||
|
|||||||
59
server/src/active-streams.ts
Normal file
59
server/src/active-streams.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type SseStreamEvent = {
|
||||||
|
event: string;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseStreamListener = (event: SseStreamEvent) => void;
|
||||||
|
|
||||||
|
export class ActiveSseStream {
|
||||||
|
private readonly events: SseStreamEvent[] = [];
|
||||||
|
private readonly listeners = new Set<SseStreamListener>();
|
||||||
|
private completed = false;
|
||||||
|
private resolveDone!: () => void;
|
||||||
|
|
||||||
|
readonly done: Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.done = new Promise((resolve) => {
|
||||||
|
this.resolveDone = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCompleted() {
|
||||||
|
return this.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data: unknown) {
|
||||||
|
if (this.completed) return;
|
||||||
|
const entry = { event, data };
|
||||||
|
this.events.push(entry);
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(finalEvent?: SseStreamEvent) {
|
||||||
|
if (this.completed) return;
|
||||||
|
if (finalEvent) {
|
||||||
|
this.emit(finalEvent.event, finalEvent.data);
|
||||||
|
}
|
||||||
|
this.completed = true;
|
||||||
|
this.listeners.clear();
|
||||||
|
this.resolveDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: SseStreamListener) {
|
||||||
|
for (const event of this.events) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.completed) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
|
|||||||
z.string().trim().url().optional()
|
z.string().trim().url().optional()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DEFAULT_HERMES_AGENT_API_BASE_URL = "http://127.0.0.1:8642/v1";
|
||||||
|
|
||||||
|
const HermesAgentApiBaseUrlSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||||
|
z.string().trim().url().default(DEFAULT_HERMES_AGENT_API_BASE_URL)
|
||||||
|
);
|
||||||
|
|
||||||
const ChatWebSearchEngineSchema = z.preprocess(
|
const ChatWebSearchEngineSchema = z.preprocess(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (typeof value !== "string") return value;
|
if (typeof value !== "string") return value;
|
||||||
@@ -59,6 +66,9 @@ 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(),
|
||||||
|
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||||
|
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||||
|
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||||
EXA_API_KEY: z.string().optional(),
|
EXA_API_KEY: z.string().optional(),
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -385,6 +385,10 @@ function normalizeIncomingMessages(messages: ChatMessage[]) {
|
|||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||||
|
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
||||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||||
|
|
||||||
@@ -853,6 +857,20 @@ function extractResponsesText(response: any, fallback = "") {
|
|||||||
return parts.join("") || fallback;
|
return parts.join("") || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatCompletionContent(message: any) {
|
||||||
|
if (typeof message?.content === "string") return message.content;
|
||||||
|
if (!Array.isArray(message?.content)) return "";
|
||||||
|
|
||||||
|
return message.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (typeof part?.text === "string") return part.text;
|
||||||
|
if (typeof part?.content === "string") return part.content;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
function getUnstreamedText(finalText: string, streamedText: string) {
|
||||||
if (!finalText) return "";
|
if (!finalText) return "";
|
||||||
if (!streamedText) return finalText;
|
if (!streamedText) return finalText;
|
||||||
@@ -1093,6 +1111,26 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainIncomingMessages(params.messages),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractChatCompletionContent(message),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response: completion, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function* runToolAwareOpenAIChatStream(
|
export async function* runToolAwareOpenAIChatStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
@@ -1354,3 +1392,41 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function* runPlainChatCompletionsStream(
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainIncomingMessages(params.messages),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
text += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { env } from "../env.js";
|
||||||
|
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
export type ProviderModelSnapshot = {
|
export type ProviderModelSnapshot = {
|
||||||
@@ -8,9 +9,9 @@ export type ProviderModelSnapshot = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
|
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||||
|
|
||||||
const providers: Provider[] = ["openai", "anthropic", "xai"];
|
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
const modelCatalog: ModelCatalogSnapshot = {
|
const modelCatalog: ModelCatalogSnapshot = {
|
||||||
@@ -19,6 +20,10 @@ const modelCatalog: ModelCatalogSnapshot = {
|
|||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getCatalogProviders(): Provider[] {
|
||||||
|
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqSorted(models: string[]) {
|
function uniqSorted(models: string[]) {
|
||||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
@@ -59,10 +64,17 @@ async function fetchProviderModels(provider: Provider) {
|
|||||||
return uniqSorted(page.data.map((model) => model.id));
|
return uniqSorted(page.data.map((model) => model.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "xai") {
|
||||||
const page = await xaiClient().models.list();
|
const page = await xaiClient().models.list();
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
return uniqSorted(page.data.map((model) => model.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const page = await hermesAgentClient().models.list();
|
||||||
|
const models = page.data.map((model) => model.id);
|
||||||
|
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||||
|
return uniqSorted(models);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||||
try {
|
try {
|
||||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||||
@@ -75,7 +87,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err?.message ?? String(err);
|
const message = err?.message ?? String(err);
|
||||||
modelCatalog[provider] = {
|
modelCatalog[provider] = {
|
||||||
models: [],
|
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
|
||||||
loadedAt: new Date().toISOString(),
|
loadedAt: new Date().toISOString(),
|
||||||
error: message,
|
error: message,
|
||||||
};
|
};
|
||||||
@@ -84,25 +96,18 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
||||||
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger)));
|
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||||
return {
|
const snapshot: ModelCatalogSnapshot = {};
|
||||||
openai: {
|
for (const provider of getCatalogProviders()) {
|
||||||
models: [...modelCatalog.openai.models],
|
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||||
loadedAt: modelCatalog.openai.loadedAt,
|
snapshot[provider] = {
|
||||||
error: modelCatalog.openai.error,
|
models: [...entry.models],
|
||||||
},
|
loadedAt: entry.loadedAt,
|
||||||
anthropic: {
|
error: entry.error,
|
||||||
models: [...modelCatalog.anthropic.models],
|
|
||||||
loadedAt: modelCatalog.anthropic.loadedAt,
|
|
||||||
error: modelCatalog.anthropic.error,
|
|
||||||
},
|
|
||||||
xai: {
|
|
||||||
models: [...modelCatalog.xai.models],
|
|
||||||
loadedAt: modelCatalog.xai.loadedAt,
|
|
||||||
error: modelCatalog.xai.error,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
|
|
||||||
function asProviderEnum(p: Provider) {
|
function asProviderEnum(p: Provider) {
|
||||||
// Prisma enum values match these strings.
|
return toPrismaProvider(p);
|
||||||
return p;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
||||||
@@ -84,6 +84,23 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
|
} else if (req.provider === "hermes-agent") {
|
||||||
|
const client = hermesAgentClient();
|
||||||
|
const r = await runPlainChatCompletions({
|
||||||
|
client,
|
||||||
|
model: req.model,
|
||||||
|
messages: req.messages,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
|
chatId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
raw = r.raw;
|
||||||
|
outText = r.text;
|
||||||
|
usage = r.usage;
|
||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
|
|||||||
31
server/src/llm/provider-ids.ts
Normal file
31
server/src/llm/provider-ids.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
|
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||||
|
|
||||||
|
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||||
|
return provider === "hermes-agent" ? "hermes_agent" : provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||||
|
if (provider === null || provider === undefined) return null;
|
||||||
|
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
|
||||||
|
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||||
|
const next: Record<string, any> = { ...value };
|
||||||
|
if ("initiatedProvider" in next) {
|
||||||
|
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
|
||||||
|
}
|
||||||
|
if ("lastUsedProvider" in next) {
|
||||||
|
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
|
||||||
|
}
|
||||||
|
if ("provider" in next) {
|
||||||
|
next.provider = fromPrismaProvider(next.provider);
|
||||||
|
}
|
||||||
|
if (Array.isArray(next.calls)) {
|
||||||
|
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
@@ -13,6 +13,18 @@ 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 isHermesAgentConfigured() {
|
||||||
|
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hermesAgentClient() {
|
||||||
|
if (!env.HERMES_AGENT_API_KEY) throw new Error("HERMES_AGENT_API_KEY not set");
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: env.HERMES_AGENT_API_KEY,
|
||||||
|
baseURL: env.HERMES_AGENT_API_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function anthropicClient() {
|
export function anthropicClient() {
|
||||||
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
||||||
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import {
|
import {
|
||||||
buildToolLogMessageData,
|
buildToolLogMessageData,
|
||||||
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
type ToolExecutionEvent,
|
type ToolExecutionEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
type StreamUsage = {
|
type StreamUsage = {
|
||||||
@@ -38,7 +40,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
? await prisma.llmCall.create({
|
? await prisma.llmCall.create({
|
||||||
data: {
|
data: {
|
||||||
chatId,
|
chatId,
|
||||||
provider: req.provider as any,
|
provider: toPrismaProvider(req.provider) as any,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
request: req as any,
|
request: req as any,
|
||||||
},
|
},
|
||||||
@@ -51,14 +53,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
prisma.chat.update({
|
prisma.chat.update({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: {
|
data: {
|
||||||
lastUsedProvider: req.provider as any,
|
lastUsedProvider: toPrismaProvider(req.provider) as any,
|
||||||
lastUsedModel: req.model,
|
lastUsedModel: req.model,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.chat.updateMany({
|
prisma.chat.updateMany({
|
||||||
where: { id: chatId, initiatedProvider: null },
|
where: { id: chatId, initiatedProvider: null },
|
||||||
data: {
|
data: {
|
||||||
initiatedProvider: req.provider as any,
|
initiatedProvider: toPrismaProvider(req.provider) as any,
|
||||||
initiatedModel: req.model,
|
initiatedModel: req.model,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -72,8 +74,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
const streamEvents =
|
const streamEvents =
|
||||||
req.provider === "openai"
|
req.provider === "openai"
|
||||||
? runToolAwareOpenAIChatStream({
|
? runToolAwareOpenAIChatStream({
|
||||||
@@ -88,6 +90,19 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId: chatId ?? undefined,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
: req.provider === "hermes-agent"
|
||||||
|
? runPlainChatCompletionsStream({
|
||||||
|
client,
|
||||||
|
model: req.model,
|
||||||
|
messages: req.messages,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
|
chatId: chatId ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
: runToolAwareChatCompletionsStream({
|
: runToolAwareChatCompletionsStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||||
|
|
||||||
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
export type ChatImageAttachment = {
|
export type ChatImageAttachment = {
|
||||||
kind: "image";
|
kind: "image";
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { ActiveSseStream, type SseStreamEvent } from "./active-streams.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { requireAdmin } from "./auth.js";
|
import { requireAdmin } from "./auth.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -120,6 +124,26 @@ const CompletionMessageSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CompletionStreamBody = z
|
||||||
|
.object({
|
||||||
|
chatId: z.string().optional(),
|
||||||
|
persist: z.boolean().optional(),
|
||||||
|
provider: ProviderSchema,
|
||||||
|
model: z.string().min(1),
|
||||||
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.persist === false && value.chatId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "chatId must be omitted when persist is false",
|
||||||
|
path: ["chatId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
if (!attachments?.length) return metadata as any;
|
if (!attachments?.length) return metadata as any;
|
||||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
@@ -293,6 +317,246 @@ function buildSseHeaders(originHeader: string | undefined) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||||
|
|
||||||
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown) {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||||
|
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||||
|
reply.raw.write(`event: ${event.event}\n`);
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(event.data)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
||||||
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
|
let unsubscribe = () => {};
|
||||||
|
let closed = false;
|
||||||
|
const closedPromise = new Promise<void>((resolve) => {
|
||||||
|
const onClose = () => {
|
||||||
|
closed = true;
|
||||||
|
unsubscribe();
|
||||||
|
reply.raw.off("close", onClose);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
reply.raw.on("close", onClose);
|
||||||
|
stream.done.finally(() => {
|
||||||
|
reply.raw.off("close", onClose);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe = stream.subscribe((event) => writeSseEvent(reply, event));
|
||||||
|
await Promise.race([stream.done, closedPromise]);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
if (!closed && !reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
||||||
|
if (ev.type === "tool_call") return { event: "tool_call", data: ev.event };
|
||||||
|
return { event: ev.type, data: ev };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||||
|
const stream = new ActiveSseStream();
|
||||||
|
activeChatStreams.set(chatId, stream);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let sawTerminalEvent = false;
|
||||||
|
try {
|
||||||
|
for await (const ev of runMultiplexStream(body)) {
|
||||||
|
const event = mapChatStreamEvent(ev);
|
||||||
|
if (ev.type === "done" || ev.type === "error") {
|
||||||
|
sawTerminalEvent = true;
|
||||||
|
stream.complete(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
stream.emit(event.event, event.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawTerminalEvent) {
|
||||||
|
stream.complete({ event: "error", data: { message: "chat stream ended unexpectedly" } });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
|
} finally {
|
||||||
|
activeChatStreams.delete(chatId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const query = body.query?.trim();
|
||||||
|
if (!query) {
|
||||||
|
stream.complete({ event: "error", data: { message: "query is required" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exa = exaClient();
|
||||||
|
const searchPromise = exa.search(query, {
|
||||||
|
type: body.type ?? "auto",
|
||||||
|
numResults: body.numResults ?? 10,
|
||||||
|
includeDomains: body.includeDomains,
|
||||||
|
excludeDomains: body.excludeDomains,
|
||||||
|
moderation: true,
|
||||||
|
userLocation: "US",
|
||||||
|
contents: false,
|
||||||
|
} as any);
|
||||||
|
const answerPromise = exa.answer(query, {
|
||||||
|
text: true,
|
||||||
|
model: "exa",
|
||||||
|
userLocation: "US",
|
||||||
|
});
|
||||||
|
|
||||||
|
let searchResponse: any | null = null;
|
||||||
|
let answerResponse: any | null = null;
|
||||||
|
let enrichedResults: any[] | null = null;
|
||||||
|
let searchError: string | null = null;
|
||||||
|
let answerError: string | null = null;
|
||||||
|
|
||||||
|
const searchSettled = searchPromise.then(
|
||||||
|
async (value) => {
|
||||||
|
searchResponse = value;
|
||||||
|
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
|
||||||
|
stream.emit("search_results", {
|
||||||
|
requestId: value?.requestId ?? null,
|
||||||
|
results: previewResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
|
||||||
|
if (!urls.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentsResponse = await exa.getContents(urls, {
|
||||||
|
text: { maxCharacters: 1200 },
|
||||||
|
highlights: {
|
||||||
|
query,
|
||||||
|
maxCharacters: 320,
|
||||||
|
numSentences: 2,
|
||||||
|
highlightsPerUrl: 2,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
const byUrl = new Map<string, any>();
|
||||||
|
for (const contentItem of contentsResponse?.results ?? []) {
|
||||||
|
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedResults = (value?.results ?? []).map((result: any) => {
|
||||||
|
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
|
||||||
|
if (!contentItem) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
text: contentItem.text ?? result.text ?? null,
|
||||||
|
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
|
||||||
|
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.emit("search_results", {
|
||||||
|
requestId: value?.requestId ?? null,
|
||||||
|
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// keep preview results if content enrichment fails
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
searchError = reason?.message ?? String(reason);
|
||||||
|
stream.emit("search_error", { error: searchError });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerSettled = answerPromise.then(
|
||||||
|
(value) => {
|
||||||
|
answerResponse = value;
|
||||||
|
stream.emit("answer", {
|
||||||
|
answerText: parseAnswerText(value),
|
||||||
|
answerRequestId: value?.requestId ?? null,
|
||||||
|
answerCitations: (value?.citations as any) ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
answerError = reason?.message ?? String(reason);
|
||||||
|
stream.emit("answer_error", { error: answerError });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([searchSettled, answerSettled]);
|
||||||
|
|
||||||
|
const latencyMs = Math.round(performance.now() - startedAt);
|
||||||
|
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
|
||||||
|
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
||||||
|
const answerText = parseAnswerText(answerResponse);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.search.update({
|
||||||
|
where: { id: searchId },
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
title: normalizedTitle,
|
||||||
|
requestId: searchResponse?.requestId ?? null,
|
||||||
|
rawResponse: searchResponse as any,
|
||||||
|
latencyMs,
|
||||||
|
error: searchError,
|
||||||
|
answerText,
|
||||||
|
answerRequestId: answerResponse?.requestId ?? null,
|
||||||
|
answerCitations: (answerResponse?.citations as any) ?? null,
|
||||||
|
answerRawResponse: answerResponse as any,
|
||||||
|
answerError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.searchResult.deleteMany({ where: { searchId } });
|
||||||
|
if (rows.length) {
|
||||||
|
await tx.searchResult.createMany({ data: rows as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = await prisma.search.findUnique({
|
||||||
|
where: { id: searchId },
|
||||||
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!search) {
|
||||||
|
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||||
|
} else {
|
||||||
|
stream.complete({ event: "done", data: { search } });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
try {
|
||||||
|
await prisma.search.update({
|
||||||
|
where: { id: searchId },
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
title: normalizedTitle,
|
||||||
|
latencyMs: Math.round(performance.now() - startedAt),
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// keep the stream terminal event even if the backing search row disappeared
|
||||||
|
}
|
||||||
|
stream.complete({ event: "error", data: { message } });
|
||||||
|
} finally {
|
||||||
|
activeSearchStreams.delete(searchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: FastifyInstance) {
|
export async function registerRoutes(app: FastifyInstance) {
|
||||||
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
||||||
|
|
||||||
@@ -306,6 +570,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { providers: getModelCatalogSnapshot() };
|
return { providers: getModelCatalogSnapshot() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/v1/active-runs", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
return {
|
||||||
|
chats: Array.from(activeChatStreams.keys()),
|
||||||
|
searches: Array.from(activeSearchStreams.keys()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
@@ -322,7 +594,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { chats };
|
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
@@ -330,7 +602,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const Body = z
|
const Body = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]).optional(),
|
provider: ProviderSchema.optional(),
|
||||||
model: z.string().trim().min(1).optional(),
|
model: z.string().trim().min(1).optional(),
|
||||||
messages: z.array(CompletionMessageSchema).optional(),
|
messages: z.array(CompletionMessageSchema).optional(),
|
||||||
})
|
})
|
||||||
@@ -356,9 +628,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.create({
|
const chat = await prisma.chat.create({
|
||||||
data: {
|
data: {
|
||||||
title: body.title,
|
title: body.title,
|
||||||
initiatedProvider: body.provider as any,
|
initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
initiatedModel: body.model,
|
initiatedModel: body.model,
|
||||||
lastUsedProvider: body.provider as any,
|
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
lastUsedModel: body.model,
|
lastUsedModel: body.model,
|
||||||
messages: body.messages?.length
|
messages: body.messages?.length
|
||||||
? {
|
? {
|
||||||
@@ -382,7 +654,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -413,7 +685,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/title/suggest", async (req) => {
|
app.post("/v1/chats/title/suggest", async (req) => {
|
||||||
@@ -438,7 +710,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: existing };
|
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
@@ -459,7 +731,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/v1/chats/:chatId", async (req) => {
|
app.delete("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -579,7 +851,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
@@ -695,162 +967,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const query = body.query?.trim() || existing.query?.trim();
|
const query = body.query?.trim() || existing.query?.trim();
|
||||||
if (!query) return app.httpErrors.badRequest("query is required");
|
if (!query) return app.httpErrors.badRequest("query is required");
|
||||||
|
|
||||||
const startedAt = performance.now();
|
const existingStream = activeSearchStreams.get(searchId);
|
||||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
if (existingStream) {
|
||||||
|
return streamActiveRun(req, reply, existingStream);
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
|
||||||
|
|
||||||
const send = (event: string, data: any) => {
|
|
||||||
if (reply.raw.writableEnded) return;
|
|
||||||
reply.raw.write(`event: ${event}\n`);
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const exa = exaClient();
|
|
||||||
const searchPromise = exa.search(query, {
|
|
||||||
type: body.type ?? "auto",
|
|
||||||
numResults: body.numResults ?? 10,
|
|
||||||
includeDomains: body.includeDomains,
|
|
||||||
excludeDomains: body.excludeDomains,
|
|
||||||
moderation: true,
|
|
||||||
userLocation: "US",
|
|
||||||
contents: false,
|
|
||||||
} as any);
|
|
||||||
const answerPromise = exa.answer(query, {
|
|
||||||
text: true,
|
|
||||||
model: "exa",
|
|
||||||
userLocation: "US",
|
|
||||||
});
|
|
||||||
|
|
||||||
let searchResponse: any | null = null;
|
|
||||||
let answerResponse: any | null = null;
|
|
||||||
let enrichedResults: any[] | null = null;
|
|
||||||
let searchError: string | null = null;
|
|
||||||
let answerError: string | null = null;
|
|
||||||
|
|
||||||
const searchSettled = searchPromise.then(
|
|
||||||
async (value) => {
|
|
||||||
searchResponse = value;
|
|
||||||
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
|
|
||||||
send("search_results", {
|
|
||||||
requestId: value?.requestId ?? null,
|
|
||||||
results: previewResults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
|
|
||||||
if (!urls.length) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentsResponse = await exa.getContents(urls, {
|
|
||||||
text: { maxCharacters: 1200 },
|
|
||||||
highlights: {
|
|
||||||
query,
|
|
||||||
maxCharacters: 320,
|
|
||||||
numSentences: 2,
|
|
||||||
highlightsPerUrl: 2,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
const byUrl = new Map<string, any>();
|
|
||||||
for (const contentItem of contentsResponse?.results ?? []) {
|
|
||||||
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enrichedResults = (value?.results ?? []).map((result: any) => {
|
const stream = new ActiveSseStream();
|
||||||
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
|
activeSearchStreams.set(searchId, stream);
|
||||||
if (!contentItem) return result;
|
void executeSearchRunStream(searchId, { ...body, query }, stream);
|
||||||
return {
|
return streamActiveRun(req, reply, stream);
|
||||||
...result,
|
|
||||||
text: contentItem.text ?? result.text ?? null,
|
|
||||||
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
|
|
||||||
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
send("search_results", {
|
app.post("/v1/searches/:searchId/run/stream/attach", async (req, reply) => {
|
||||||
requestId: value?.requestId ?? null,
|
requireAdmin(req);
|
||||||
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
|
const Params = z.object({ searchId: z.string() });
|
||||||
});
|
const { searchId } = Params.parse(req.params);
|
||||||
} catch {
|
const stream = activeSearchStreams.get(searchId);
|
||||||
// keep preview results if content enrichment fails
|
if (!stream) return app.httpErrors.notFound("active search stream not found");
|
||||||
}
|
return streamActiveRun(req, reply, stream);
|
||||||
},
|
|
||||||
(reason) => {
|
|
||||||
searchError = reason?.message ?? String(reason);
|
|
||||||
send("search_error", { error: searchError });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const answerSettled = answerPromise.then(
|
|
||||||
(value) => {
|
|
||||||
answerResponse = value;
|
|
||||||
send("answer", {
|
|
||||||
answerText: parseAnswerText(value),
|
|
||||||
answerRequestId: value?.requestId ?? null,
|
|
||||||
answerCitations: (value?.citations as any) ?? null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(reason) => {
|
|
||||||
answerError = reason?.message ?? String(reason);
|
|
||||||
send("answer_error", { error: answerError });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([searchSettled, answerSettled]);
|
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - startedAt);
|
|
||||||
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
|
|
||||||
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
|
||||||
const answerText = parseAnswerText(answerResponse);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.search.update({
|
|
||||||
where: { id: searchId },
|
|
||||||
data: {
|
|
||||||
query,
|
|
||||||
title: normalizedTitle,
|
|
||||||
requestId: searchResponse?.requestId ?? null,
|
|
||||||
rawResponse: searchResponse as any,
|
|
||||||
latencyMs,
|
|
||||||
error: searchError,
|
|
||||||
answerText,
|
|
||||||
answerRequestId: answerResponse?.requestId ?? null,
|
|
||||||
answerCitations: (answerResponse?.citations as any) ?? null,
|
|
||||||
answerRawResponse: answerResponse as any,
|
|
||||||
answerError,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.searchResult.deleteMany({ where: { searchId } });
|
|
||||||
if (rows.length) {
|
|
||||||
await tx.searchResult.createMany({ data: rows as any });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
|
||||||
where: { id: searchId },
|
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
|
||||||
});
|
|
||||||
if (!search) {
|
|
||||||
send("error", { message: "search not found" });
|
|
||||||
} else {
|
|
||||||
send("done", { search });
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
await prisma.search.update({
|
|
||||||
where: { id: searchId },
|
|
||||||
data: {
|
|
||||||
query,
|
|
||||||
title: normalizedTitle,
|
|
||||||
latencyMs: Math.round(performance.now() - startedAt),
|
|
||||||
error: err?.message ?? String(err),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
send("error", { message: err?.message ?? String(err) });
|
|
||||||
} finally {
|
|
||||||
reply.raw.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/chats/:chatId", async (req) => {
|
app.get("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -863,7 +997,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
@@ -895,13 +1029,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { message: msg };
|
return { message: msg };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/v1/chats/:chatId/stream/attach", async (req, reply) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const Params = z.object({ chatId: z.string() });
|
||||||
|
const { chatId } = Params.parse(req.params);
|
||||||
|
const stream = activeChatStreams.get(chatId);
|
||||||
|
if (!stream) return app.httpErrors.notFound("active chat stream not found");
|
||||||
|
return streamActiveRun(req, reply, stream);
|
||||||
|
});
|
||||||
|
|
||||||
// Main: create a completion via provider+model and store everything.
|
// Main: create a completion via provider+model and store everything.
|
||||||
app.post("/v1/chat-completions", async (req) => {
|
app.post("/v1/chat-completions", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
@@ -935,27 +1078,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
const Body = z
|
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||||
.object({
|
|
||||||
chatId: z.string().optional(),
|
|
||||||
persist: z.boolean().optional(),
|
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
|
||||||
model: z.string().min(1),
|
|
||||||
messages: z.array(CompletionMessageSchema),
|
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
|
||||||
maxTokens: z.number().int().positive().optional(),
|
|
||||||
})
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
if (value.persist === false && value.chatId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "chatId must be omitted when persist is false",
|
|
||||||
path: ["chatId"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = Body.safeParse(req.body);
|
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
@@ -970,23 +1093,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.persist !== false && body.chatId) {
|
||||||
|
if (activeChatStreams.has(body.chatId)) {
|
||||||
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
|
}
|
||||||
|
const stream = startActiveChatStream(body.chatId, body);
|
||||||
|
return streamActiveRun(req, reply, stream);
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
const send = (event: string, data: any) => {
|
|
||||||
reply.raw.write(`event: ${event}\n`);
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
for await (const ev of runMultiplexStream(body)) {
|
||||||
if (ev.type === "meta") send("meta", ev);
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
else if (ev.type === "tool_call") send("tool_call", ev.event);
|
|
||||||
else if (ev.type === "delta") send("delta", ev);
|
|
||||||
else if (ev.type === "done") send("done", ev);
|
|
||||||
else if (ev.type === "error") send("error", ev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
|
}
|
||||||
return reply;
|
return reply;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
34
server/tests/active-streams.test.ts
Normal file
34
server/tests/active-streams.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ActiveSseStream, type SseStreamEvent } from "../src/active-streams.js";
|
||||||
|
|
||||||
|
test("ActiveSseStream replays buffered events to late subscribers", () => {
|
||||||
|
const stream = new ActiveSseStream();
|
||||||
|
stream.emit("delta", { text: "hel" });
|
||||||
|
stream.emit("delta", { text: "lo" });
|
||||||
|
|
||||||
|
const events: SseStreamEvent[] = [];
|
||||||
|
const unsubscribe = stream.subscribe((event) => events.push(event));
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{ event: "delta", data: { text: "hel" } },
|
||||||
|
{ event: "delta", data: { text: "lo" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ActiveSseStream replays terminal events after completion", async () => {
|
||||||
|
const stream = new ActiveSseStream();
|
||||||
|
stream.emit("delta", { text: "done" });
|
||||||
|
stream.complete({ event: "done", data: { text: "done" } });
|
||||||
|
await stream.done;
|
||||||
|
|
||||||
|
const events: SseStreamEvent[] = [];
|
||||||
|
stream.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
assert.equal(stream.isCompleted, true);
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{ event: "delta", data: { text: "done" } },
|
||||||
|
{ event: "done", data: { text: "done" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import {
|
||||||
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
@@ -105,3 +106,37 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
|||||||
);
|
);
|
||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Hi" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
runPlainChatCompletionsStream({
|
||||||
|
client: client as any,
|
||||||
|
model: "hermes-agent",
|
||||||
|
messages: [{ role: "user", content: "Say hi" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(requestBody.model, "hermes-agent");
|
||||||
|
assert.equal(requestBody.stream, true);
|
||||||
|
assert.equal("tools" in requestBody, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["delta", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
|
});
|
||||||
|
|||||||
12
server/tests/provider-ids.test.ts
Normal file
12
server/tests/provider-ids.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
|
||||||
|
|
||||||
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||||
|
initiatedProvider: "hermes-agent",
|
||||||
|
lastUsedProvider: "xai",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
|
|||||||
|
|
||||||
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
||||||
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
||||||
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
|
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` | `hermes-agent` (default: `openai`)
|
||||||
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
||||||
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
|
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"],
|
||||||
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||||
@@ -74,6 +76,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +162,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
|||||||
return PROVIDER_FALLBACK_MODELS[provider];
|
return PROVIDER_FALLBACK_MODELS[provider];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||||
|
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
||||||
if (fallback && options.includes(fallback)) return fallback;
|
if (fallback && options.includes(fallback)) return fallback;
|
||||||
if (preferred && options.includes(preferred)) return preferred;
|
if (preferred && options.includes(preferred)) return preferred;
|
||||||
@@ -202,6 +209,7 @@ async function main() {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
@@ -1257,8 +1265,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cycleProvider() {
|
function cycleProvider() {
|
||||||
const currentIndex = PROVIDERS.indexOf(provider);
|
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||||
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
|
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||||
|
const currentIndex = Math.max(0, cycleProviders.indexOf(provider));
|
||||||
|
const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai";
|
||||||
provider = nextProvider;
|
provider = nextProvider;
|
||||||
syncModelForProvider();
|
syncModelForProvider();
|
||||||
updateUI();
|
updateUI();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogResponse = {
|
export type ModelCatalogResponse = {
|
||||||
providers: Record<Provider, ProviderModelInfo>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
|
|||||||
BIN
web/public/StalinistOne-Regular.ttf
Normal file
BIN
web/public/StalinistOne-Regular.ttf
Normal file
Binary file not shown.
743
web/src/App.tsx
743
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,14 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "StalinistOne";
|
||||||
|
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: 235 45% 4%;
|
--background: 235 45% 4%;
|
||||||
@@ -57,8 +65,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sybil-wordmark {
|
.sybil-wordmark {
|
||||||
font-family: "Orbitron", "Inter", sans-serif;
|
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 400;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -198,17 +206,31 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-content code {
|
.md-content code {
|
||||||
background: hsl(288 22% 23%);
|
background: hsl(249 40% 10% / 0.78);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.3rem;
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content pre {
|
.md-content pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 0.5rem;
|
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||||
background: hsl(287 28% 13%);
|
border-radius: 0.625rem;
|
||||||
padding: 0.6rem 0.75rem;
|
background: hsl(249 40% 10% / 0.82);
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre code {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content a {
|
.md-content a {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export type CompletionRequestMessage = {
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -136,7 +136,12 @@ export type ProviderModelInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogResponse = {
|
export type ModelCatalogResponse = {
|
||||||
providers: Record<Provider, ProviderModelInfo>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActiveRunsResponse = {
|
||||||
|
chats: string[];
|
||||||
|
searches: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CompletionResponse = {
|
type CompletionResponse = {
|
||||||
@@ -217,6 +222,10 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getActiveRuns() {
|
||||||
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||||
|
}
|
||||||
|
|
||||||
export async function createChat(input?: string | CreateChatRequest) {
|
export async function createChat(input?: string | CreateChatRequest) {
|
||||||
const body = typeof input === "string" ? { title: input } : input ?? {};
|
const body = typeof input === "string" ? { title: input } : input ?? {};
|
||||||
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
@@ -333,6 +342,85 @@ type RunSearchStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(eventName, 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 runSearchStream(
|
export async function runSearchStream(
|
||||||
searchId: string,
|
searchId: string,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -437,6 +525,30 @@ export async function runSearchStream(
|
|||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await readSseStream(response, (eventName, payload) => {
|
||||||
|
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||||
|
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||||
|
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||||
|
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCompletion(body: {
|
export async function runCompletion(body: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
@@ -556,3 +668,26 @@ export async function runCompletionStream(
|
|||||||
}
|
}
|
||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user