42 Commits

Author SHA1 Message Date
29c6dce0e5 ios: show provider/model in subtitle 2026-05-09 21:19:34 -07:00
5855b7edb8 ios: keyboard dismissal behavior 2026-05-09 20:49:27 -07:00
ac6d55f617 ios: 1.9 2026-05-06 22:35:00 -07:00
1e045db7f4 ios: fix pull to refresh 2026-05-06 22:34:17 -07:00
12b3d8c5ad ios: get rid of "assistant is typing" 2026-05-06 21:56:19 -07:00
bd0200ac98 ios: quick question UI 2026-05-06 21:53:51 -07:00
0c9b4d1ed3 web: better overscroll behavior 2026-05-06 01:35:12 -07:00
30656842a7 web: nicer code blocks 2026-05-04 22:18:33 -07:00
8b580fd3e1 add hermes agent provider 2026-05-04 21:52:39 -07:00
195e157e1a ios: sidebar swipe 2026-05-04 21:07:55 -07:00
c5dbd12587 ios: unify sidebars 2026-05-04 20:19:58 -07:00
be072fd46d ios: add multi-polling support 2026-05-04 20:14:16 -07:00
f514c42de6 backend, web: support for resuming streams 2026-05-04 09:12:31 -07:00
70a60edf1c ios: enable shitty copy from transcript 2026-05-03 23:26:58 -07:00
91ef28bf29 ios: more ambitious gestures / navigation 2026-05-03 23:06:39 -07:00
bb713f8806 original assets 2026-05-03 22:13:43 -07:00
e6cf344527 ios: appearance tweaks 2026-05-03 22:11:29 -07:00
4bc0773d35 ios: fix keyboard behavior 2026-05-03 21:52:49 -07:00
d1140d21d4 more consistent view model display between switching chats 2026-05-03 21:42:28 -07:00
0c0226e37e web: change font 2026-05-03 21:30:54 -07:00
0b94d5b3fa ios: invert scroll view technique 2026-05-03 21:21:03 -07:00
aff2531bf3 ios: fix new chat swipe 2026-05-03 21:14:10 -07:00
ee8a93a8c4 redesign bottom bar 2026-05-03 21:06:20 -07:00
53a3b722ec ios: some iPad fixes 2026-05-03 18:38:16 -07:00
ae783020ef character opacity tweak 2026-05-03 18:15:13 -07:00
39acefb55a add Character animations 2026-05-03 18:11:53 -07:00
e6fe63280a ios: redesign top navbar 2026-05-03 17:52:57 -07:00
2403dd99ae web: busy character animation 2026-05-03 17:52:50 -07:00
89bd418566 web: idle character animation 2026-05-03 17:00:45 -07:00
e02168854c ios: better network handling 2026-05-03 16:42:49 -07:00
3820007289 web: css: better indenting with nested lists 2026-05-03 16:03:19 -07:00
5d046ca173 ios: dismiss kb on submit 2026-05-03 15:59:27 -07:00
bca408c971 ios: specify product name 2026-05-03 00:17:08 -07:00
2f265fd847 ios: mac catalyst kb shortcuts 2026-05-02 23:50:51 -07:00
29e340fd08 quick question feature 2026-05-02 23:48:01 -07:00
6fbcaecbf8 sidebar: move settings 2026-05-02 23:20:32 -07:00
519ebd15dd ios: mac catalyst target 2026-05-02 23:18:00 -07:00
8051dd2c71 web: nicer tables 2026-05-02 23:17:52 -07:00
2313e560e8 fix streaming 2026-05-02 23:09:39 -07:00
94565298d8 better new chat animation 2026-05-02 22:51:59 -07:00
7360604136 ios: swipe to create new conversation 2026-05-02 22:46:25 -07:00
ca6b5e0807 web: keyboard shortcuts 2026-05-02 22:45:15 -07:00
60 changed files with 7363 additions and 1219 deletions

View File

@@ -12,6 +12,9 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_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:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}

View File

@@ -33,11 +33,29 @@ Chat upload limits:
"providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }
"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.
- `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
@@ -45,9 +63,29 @@ Chat upload limits:
- Response: `{ "chats": ChatSummary[] }`
### `POST /v1/chats`
- Body: `{ "title"?: string }`
- Body:
```json
{
"title": "optional title",
"provider": "optional openai|anthropic|xai|hermes-agent",
"model": "optional model id",
"messages": [
{
"role": "system|user|assistant|tool",
"content": "string",
"name": "optional",
"attachments": []
}
]
}
```
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- `provider` and `model` must be supplied together when present.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
@@ -116,7 +154,7 @@ Notes:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai",
"provider": "openai|anthropic|xai|hermes-agent",
"model": "string",
"messages": [
{
@@ -170,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.
- 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 `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 `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 `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`.
- `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.
@@ -240,6 +279,32 @@ Search run notes:
- Persists answer text/citations + ranked results.
- 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
`ChatSummary`
@@ -249,9 +314,9 @@ Search run notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null"
}
```
@@ -297,9 +362,9 @@ Search run notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null",
"messages": [Message]
}

View File

@@ -4,6 +4,7 @@ This document defines the server-sent events (SSE) contract for chat completions
Endpoint:
- `POST /v1/chat-completions/stream`
- `POST /v1/chats/:chatId/stream/attach`
Transport:
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
@@ -19,7 +20,8 @@ Authentication:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai",
"persist": true,
"provider": "openai|anthropic|xai|hermes-agent",
"model": "string",
"messages": [
{
@@ -53,10 +55,29 @@ Authentication:
```
Notes:
- If `chatId` is omitted, backend creates a new chat.
- `persist` defaults to `true`.
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- 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.
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
- 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`.
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
@@ -71,13 +92,15 @@ Event order:
```json
{
"type": "meta",
"chatId": "chat-id",
"callId": "llm-call-id",
"chatId": "chat-id-or-null",
"callId": "llm-call-id-or-null",
"provider": "openai",
"model": "gpt-4.1-mini"
}
```
For `persist: false` streams, `chatId` and `callId` are `null`.
### `delta`
```json
@@ -129,8 +152,9 @@ Event order:
- `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.
- `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.
- `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.
- `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.
@@ -141,24 +165,29 @@ Event order:
Tool-enabled streaming notes (`openai`/`xai`):
- Stream still emits standard `meta`, `delta`, `done|error` events.
- Stream may emit `tool_call` events while tool calls are executed.
- `delta` events carry assistant text. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
- OpenAI Responses stream events are normalized by the backend into this SSE contract; clients do not consume OpenAI's raw Responses stream event names.
## Persistence + Consistency Model
Backend database remains source of truth.
During stream:
For persisted streams:
- Client may optimistically render accumulated `delta` text.
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
On successful completion:
On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
- Backend then emits `done`.
On failure:
On persisted failure:
- Backend records call error and emits `error`.
For `persist: false` streams:
- Client may render the same `meta`, `tool_call`, `delta`, and terminal events.
- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows.
- `done.text` is the canonical assistant text if the client later imports the result into a saved chat.
Client recommendation (for iOS/web):
1. Render deltas in real time for UX.
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.

View File

@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- `just build` will:
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
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.
## 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 target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- 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`
- Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini`
- Hermes Agent: `hermes-agent`

17
ios/Apps/Sybil/Info.plist Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -5,9 +5,90 @@ import UIKit
@main
struct SybilApp: App
{
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
SplitView()
}
.commands {
SybilCommands()
}
}
}
@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
}
}

View File

@@ -1,7 +1,9 @@
targets:
SybilApp:
type: application
platform: iOS
supportedDestinations:
- iOS
- macCatalyst
deploymentTarget: "18.0"
sources:
- Sources
@@ -12,15 +14,18 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
PRODUCT_NAME: Sybil
PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.3
CURRENT_PROJECT_VERSION: 4
MARKETING_VERSION: 1.8
CURRENT_PROJECT_VERSION: 9
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -2,9 +2,38 @@ import SwiftUI
public struct SplitView: View {
@State private var viewModel = SybilViewModel()
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@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? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
return nil
}
return SybilKeyboardActions(
newChat: {
viewModel.startNewChat()
composerFocusRequest += 1
},
newSearch: {
viewModel.startNewSearch()
composerFocusRequest += 1
},
previousConversation: {
viewModel.selectPreviousSidebarItem()
},
nextConversation: {
viewModel.selectNextSidebarItem()
}
)
}
@MainActor public init() {
SybilFontRegistry.registerIfNeeded()
@@ -26,40 +55,161 @@ public struct SplitView: View {
} else if horizontalSizeClass == .compact {
SybilPhoneShellView(viewModel: viewModel)
} else {
NavigationSplitView {
GeometryReader { proxy in
NavigationSplitView(columnVisibility: $columnVisibility) {
SybilSidebarView(viewModel: viewModel)
} detail: {
SybilWorkspaceView(viewModel: viewModel)
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
onShowSidebar: showSidebar,
onRequestNewChat: {
viewModel.startNewChat()
composerFocusRequest += 1
}
)
}
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
}
}
}
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
.task {
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
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
await viewModel.refreshAfterAppBecameActive(
refreshCollections: true,
refreshSelection: viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}
}
}
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 {
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
keyboardActions?.newChat()
}
.keyboardShortcut("n", modifiers: .command)
.disabled(keyboardActions == nil)
Button("New Search") {
keyboardActions?.newSearch()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(keyboardActions == nil)
}
CommandMenu("Conversation") {
Button("Previous Conversation") {
keyboardActions?.previousConversation()
}
.keyboardShortcut("[", modifiers: .command)
.disabled(keyboardActions == nil)
Button("Next Conversation") {
keyboardActions?.nextConversation()
}
.keyboardShortcut("]", modifiers: .command)
.disabled(keyboardActions == nil)
}
}
}
private struct SybilKeyboardActions {
var newChat: () -> Void
var newSearch: () -> Void
var previousConversation: () -> Void
var nextConversation: () -> Void
}
private struct SybilKeyboardActionsKey: FocusedValueKey {
typealias Value = SybilKeyboardActions
}
private extension FocusedValues {
var sybilKeyboardActions: SybilKeyboardActions? {
get { self[SybilKeyboardActionsKey.self] }
set { self[SybilKeyboardActionsKey.self] = newValue }
}
}

View File

@@ -49,11 +49,16 @@ actor SybilAPIClient: SybilAPIClienting {
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(
"/v1/chats",
method: "POST",
body: AnyEncodable(ChatCreateBody(title: title)),
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
responseType: ChatCreateResponse.self
)
return response.chat
@@ -116,6 +121,10 @@ actor SybilAPIClient: SybilAPIClienting {
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(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -133,43 +142,35 @@ actor SybilAPIClient: SybilAPIClienting {
)
try await stream(request: request) { eventName, dataText in
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)
}
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
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(
searchID: String,
body: SearchRunRequest,
@@ -188,34 +189,35 @@ actor SybilAPIClient: SybilAPIClienting {
)
try await stream(request: request) { eventName, dataText in
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)
}
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
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>(
_ path: String,
method: String,
@@ -498,6 +500,75 @@ actor SybilAPIClient: SybilAPIClienting {
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(
eventName: inout String,
dataLines: inout [String]
@@ -551,6 +622,7 @@ actor SybilAPIClient: SybilAPIClienting {
struct CompletionStreamRequest: Codable, Sendable {
var chatId: String?
var persist: Bool? = nil
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
@@ -558,6 +630,9 @@ struct CompletionStreamRequest: Codable, Sendable {
private struct ChatCreateBody: Encodable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct SearchCreateBody: Encodable {

View File

@@ -3,7 +3,12 @@ import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
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 deleteChat(chatID: String) async throws
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 deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func runSearchStream(
searchID: String,
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) 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)
}
}

View File

@@ -5,7 +5,8 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
@State private var hasHandledInitialTranscriptScroll = false
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
private var hasPendingAssistant: Bool {
messages.contains { message in
@@ -14,65 +15,30 @@ struct SybilChatTranscriptView: View {
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
}
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.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)
.padding(.horizontal, 14)
.padding(.vertical, 18)
.padding(.top, 18 + bottomContentInset)
.padding(.bottom, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
scrollToBottom(with: proxy, animated: false)
}
.onChange(of: messages.map(\.id)) { _, _ in
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
hasHandledInitialTranscriptScroll = true
}
.onChange(of: isSending) { _, _ in
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
}
}
}
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
if animated {
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
} else {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
.scaleEffect(x: 1, y: -1)
}
}
@@ -132,6 +98,7 @@ private struct MessageBubble: View {
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
if isUser {
@@ -264,6 +231,7 @@ private struct ToolCallActivityChip: View {
}
}
}
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(

View File

@@ -85,7 +85,7 @@ extension Theme {
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.relativeLineSpacing(.em(0.46))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in

View File

@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai
case anthropic
case xai
case hermesAgent = "hermes-agent"
public var displayName: String {
switch self {
case .openai: return "OpenAI"
case .anthropic: return "Anthropic"
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 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 var query: String?
public var title: String?
@@ -394,8 +406,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
}
public struct CompletionStreamMeta: Codable, Sendable {
public var chatId: String
public var callId: String
public var chatId: String?
public var callId: String?
public var provider: Provider
public var model: String
}

View File

@@ -22,52 +22,473 @@ enum PhoneRoute: Hashable {
struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = []
@State private var route: PhoneRoute = .draftChat
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@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 {
NavigationStack(path: $path) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
GeometryReader { proxy in
phoneStack(width: proxy.size.width)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
updatePhoneStackWidth(proxy.size.width)
}
}
.navigationDestination(for: PhoneRoute.self) { route in
SybilPhoneDestinationView(viewModel: viewModel, route: route)
.onChange(of: proxy.size.width) { _, width in
updatePhoneStackWidth(width)
}
}
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
refreshCollections: path.isEmpty,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
await viewModel.refreshAfterAppBecameActive(
refreshCollections: isSidebarOverlayPresented,
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}
}
}
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 {
@Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute]
var highlightedSelection: SidebarSelection?
var onSelect: (SidebarSelection) -> Void
var onRoute: (PhoneRoute) -> Void
var body: some View {
VStack(spacing: 0) {
@@ -83,50 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
.overlay(SybilTheme.border)
}
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
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)
}
SybilSidebarItemList(
viewModel: viewModel,
isSelected: { item in
highlightedSelection == item.selection
},
onSelect: { item in
onSelect(item.selection)
}
)
}
.background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -141,19 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
path = [.settings]
viewModel.openSettings()
onRoute(.settings)
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
viewModel.startNewSearch()
path = [.draftSearch]
onRoute(.draftSearch)
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
viewModel.startNewChat()
path = [.draftChat]
onRoute(.draftChat)
}
}
.padding(.horizontal, 18)
@@ -191,69 +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 {
@Bindable var viewModel: SybilViewModel
@Binding var composerFocusRequest: Int
let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
var body: some View {
SybilWorkspaceView(viewModel: viewModel)
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationBarTitleDisplayMode(.inline)
.task(id: route) {
applyRoute()
}
@@ -262,8 +604,14 @@ private struct SybilPhoneDestinationView: View {
private func applyRoute() {
switch route {
case let .chat(chatID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
return
}
viewModel.select(.chat(chatID))
case let .search(searchID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
return
}
viewModel.select(.search(searchID))
case .draftChat:
viewModel.startNewChat()

View File

@@ -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
}
}

View File

@@ -0,0 +1,302 @@
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() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = 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")."
}
}

View File

@@ -6,6 +6,8 @@ struct SybilSearchResultsView: View {
var isLoading: Bool
var isRunning: Bool
var isStartingChat: Bool = false
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var onStartChat: (() -> Void)? = nil
var body: some View {
@@ -98,7 +100,8 @@ struct SybilSearchResultsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 20)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -11,6 +11,12 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
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
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
var adminToken: String
var preferredProvider: Provider
var preferredModelByProvider: [Provider: String]
var quickQuestionPreferredProvider: Provider
var quickQuestionPreferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider
self.preferredModelByProvider = [
let preferredModels: [Provider: String] = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
.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[.anthropic], forKey: Keys.preferredAnthropicModel)
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? {
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
raw.removeLast()
}
guard var components = URLComponents(string: raw) else {
guard let components = URLComponents(string: raw) else {
return nil
}

View File

@@ -4,13 +4,6 @@ import SwiftUI
struct SybilSidebarView: View {
@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 {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
}
@@ -57,119 +50,14 @@ struct SybilSidebarView: View {
.overlay(SybilTheme.border)
}
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 {
SybilSidebarItemList(
viewModel: viewModel,
isSelected: isSelected,
onSelect: { item in
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)
}
}
Divider()
.overlay(SybilTheme.border)
Button {
viewModel.openSettings()
} label: {
Label("Settings", systemImage: "gearshape")
.font(.sybil(.subheadline, weight: .medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
)
}
.buttonStyle(.plain)
.padding(10)
}
.background(SybilTheme.panelGradient)
.navigationTitle("")
@@ -178,6 +66,17 @@ struct SybilSidebarView: View {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
viewModel.openSettings()
} label: {
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
}
.accessibilityLabel("Settings")
}
}
}
@@ -207,3 +106,148 @@ struct SybilSidebarView: View {
.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.refreshSidebarCollectionsFromPullToRefresh()
}
}
}
}
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")
}
}

View File

@@ -9,7 +9,7 @@ enum SybilFontRegistry {
}
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") ??
Bundle.main.url(forResource: fontName, withExtension: "ttf")
else {
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
var body: some View {
Text("SYBIL")
.font(.custom("Orbitron", size: size))
.font(.custom("Stalinist One", size: size))
.fontWeight(.black)
.tracking(0)
.foregroundStyle(SybilTheme.brandGradient)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import CoreGraphics
import Foundation
import Testing
@testable import Sybil
@@ -5,8 +6,20 @@ import Testing
private struct MockClientCallSnapshot: Sendable {
var listChats = 0
var listSearches = 0
var createChat = 0
var getChat = 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 {}
@@ -16,40 +29,136 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchesResponse: [SearchSummary]
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String?
private var completionStreamDelayNanoseconds: UInt64 = 0
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
private var completionAttachDelayNanoseconds: UInt64 = 0
private var searchStreamNetworkErrorMessage: String?
private var searchStreamDelayNanoseconds: UInt64 = 0
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
private var searchAttachDelayNanoseconds: UInt64 = 0
init(
chatsResponse: [ChatSummary] = [],
searchesResponse: [SearchSummary] = [],
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:]
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
) {
self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.activeRunsResponse = activeRunsResponse
}
func currentSnapshot() -> MockClientCallSnapshot {
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) {
completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds
}
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds
}
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
getSearchDelayNanoseconds = delayNanoseconds
}
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
searchStreamNetworkErrorMessage = message
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 {
AuthSession(authenticated: true, mode: "open")
}
func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
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()
}
func getChat(chatID: String) async throws -> ChatDetail {
snapshot.getChat += 1
if getChatDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
}
guard let detail = chatDetails[chatID] else {
throw UnexpectedClientCall()
}
@@ -66,6 +175,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse
}
@@ -75,6 +187,9 @@ private actor MockSybilClient: SybilAPIClienting {
func getSearch(searchID: String) async throws -> SearchDetail {
snapshot.getSearch += 1
if getSearchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
}
guard let detail = searchDetails[searchID] else {
throw UnexpectedClientCall()
}
@@ -93,20 +208,73 @@ private actor MockSybilClient: SybilAPIClienting {
ModelCatalogResponse(providers: [:])
}
func getActiveRuns() async throws -> ActiveRunsResponse {
snapshot.getActiveRuns += 1
return activeRunsResponse
}
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
snapshot.runCompletionStream += 1
lastCompletionStreamBody = body
if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
}
if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
}
if let completionStreamEvents {
for event in completionStreamEvents {
await onEvent(event)
}
return
}
throw UnexpectedClientCall()
}
func 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(
searchID: String,
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
if searchStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
}
if let searchStreamNetworkErrorMessage {
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
}
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
@@ -228,6 +396,33 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedItem == .chat("chat-1"))
}
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -265,3 +460,367 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(snapshot.getSearch == 1)
#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
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_300)
let chat = makeChatSummary(id: "chat-3", date: date)
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: ["chat-3": refreshedDetail]
)
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-3")
viewModel.selectedChat = initialDetail
viewModel.composer = "continue"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
viewModel.markAppInactiveForNetwork()
await sendTask.value
#expect(viewModel.errorMessage == nil)
#expect(viewModel.composer.isEmpty)
#expect(!viewModel.isSending)
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.getChat == 1)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
}
@MainActor
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_400)
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
let client = MockSybilClient(
searchDetails: ["search-3": refreshedDetail]
)
await client.setSearchStreamNetworkError(
"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 = .search("search-3")
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
viewModel.composer = "refresh me"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
viewModel.markAppInactiveForNetwork()
await sendTask.value
#expect(viewModel.errorMessage == nil)
#expect(viewModel.composer.isEmpty)
#expect(!viewModel.isSending)
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.getSearch == 1)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
}
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
let width: CGFloat = 390
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)
#expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
#expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
#expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
#expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
#expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
#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: 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))
}

View File

@@ -1,10 +1,28 @@
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
simulator_name := "iPhone 16e"
derived_data := "build/DerivedData"
default:
@just build
build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
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 \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
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}}'

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# Sybil Server
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)
## Stack
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY`
- `ANTHROPIC_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`
- `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`)

View File

@@ -11,6 +11,7 @@
"prebuild": "node scripts/ensure-prisma-client.mjs",
"dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
"start": "node dist/index.js",
"test": "node --test --import tsx tests/**/*.test.ts",
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",

View File

@@ -13,6 +13,7 @@ enum Provider {
openai
anthropic
xai
hermes_agent @map("hermes-agent")
}
enum MessageRole {

View 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);
};
}
}

View File

@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
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(
(value) => {
if (typeof value !== "string") return value;
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_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(),
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.

View File

@@ -385,6 +385,10 @@ function normalizeIncomingMessages(messages: ChatMessage[]) {
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
return messages.map((message) => buildOpenAIConversationMessage(message));
}
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
@@ -853,6 +857,26 @@ function extractResponsesText(response: any, 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) {
if (!finalText) return "";
if (!streamedText) return finalText;
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
}
function getResponseFailureMessage(response: any) {
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
@@ -1087,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(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
@@ -1113,6 +1157,9 @@ export async function* runToolAwareOpenAIChatStream(
} as any);
let roundText = "";
let streamedRoundText = "";
let roundHasToolCalls = false;
let canStreamRoundText = false;
let completedResponse: any | null = null;
const completedOutputItems: any[] = [];
@@ -1121,8 +1168,23 @@ export async function* runToolAwareOpenAIChatStream(
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
roundText += event.delta;
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
streamedRoundText += event.delta;
yield { type: "delta", text: event.delta };
}
} else if (event?.type === "response.output_item.added" && event.item) {
if (event.item.type === "function_call") {
roundHasToolCalls = true;
canStreamRoundText = false;
} else if (event.item.type === "message" && !roundHasToolCalls) {
canStreamRoundText = true;
}
} else if (event?.type === "response.output_item.done" && event.item) {
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
if (event.item.type === "function_call") {
roundHasToolCalls = true;
canStreamRoundText = false;
}
} else if (event?.type === "response.completed") {
completedResponse = event.response;
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
@@ -1144,13 +1206,18 @@ export async function* runToolAwareOpenAIChatStream(
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
if (!normalizedToolCalls.length) {
const text = extractResponsesText(completedResponse, roundText);
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
if (
!streamedRoundText &&
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
looksLikeDanglingToolIntent(text)
) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(input, text);
continue;
}
if (text) {
yield { type: "delta", text };
const unstreamedText = getUnstreamedText(text, streamedRoundText);
if (unstreamedText) {
yield { type: "delta", text: unstreamedText };
}
yield {
type: "done",
@@ -1214,6 +1281,8 @@ export async function* runToolAwareChatCompletionsStream(
} as any);
let roundText = "";
let streamedRoundText = "";
let roundHasToolCalls = false;
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
for await (const chunk of stream as any as AsyncIterable<any>) {
@@ -1224,9 +1293,16 @@ export async function* runToolAwareChatCompletionsStream(
const deltaText = choice?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
roundText += deltaText;
if (!roundHasToolCalls) {
streamedRoundText += deltaText;
yield { type: "delta", text: deltaText };
}
}
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
if (deltaToolCalls.length) {
roundHasToolCalls = true;
}
for (const toolCall of deltaToolCalls) {
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
@@ -1252,13 +1328,18 @@ export async function* runToolAwareChatCompletionsStream(
}));
if (!normalizedToolCalls.length) {
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
if (
!streamedRoundText &&
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
looksLikeDanglingToolIntent(roundText)
) {
danglingToolIntentRetries += 1;
appendDanglingToolIntentCorrection(conversation, roundText);
continue;
}
if (roundText) {
yield { type: "delta", text: roundText };
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
if (unstreamedText) {
yield { type: "delta", text: unstreamedText };
}
yield {
type: "done",
@@ -1311,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: [],
},
};
}

View File

@@ -1,5 +1,6 @@
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";
export type ProviderModelSnapshot = {
@@ -8,9 +9,9 @@ export type ProviderModelSnapshot = {
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 modelCatalog: ModelCatalogSnapshot = {
@@ -19,6 +20,10 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null },
};
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
@@ -59,8 +64,15 @@ async function fetchProviderModels(provider: Provider) {
return uniqSorted(page.data.map((model) => model.id));
}
if (provider === "xai") {
const page = await xaiClient().models.list();
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) {
@@ -75,7 +87,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
} catch (err: any) {
const message = err?.message ?? String(err);
modelCatalog[provider] = {
models: [],
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
loadedAt: new Date().toISOString(),
error: message,
};
@@ -84,25 +96,18 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
}
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 {
return {
openai: {
models: [...modelCatalog.openai.models],
loadedAt: modelCatalog.openai.loadedAt,
error: modelCatalog.openai.error,
},
anthropic: {
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,
},
const snapshot: ModelCatalogSnapshot = {};
for (const provider of getCatalogProviders()) {
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
snapshot[provider] = {
models: [...entry.models],
loadedAt: entry.loadedAt,
error: entry.error,
};
}
return snapshot;
}

View File

@@ -1,13 +1,13 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) {
// Prisma enum values match these strings.
return p;
return toPrismaProvider(p);
}
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
@@ -84,6 +84,23 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text;
usage = r.usage;
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") {
const client = anthropicClient();

View 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;
}

View File

@@ -13,6 +13,18 @@ export function xaiClient() {
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() {
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });

View File

@@ -1,20 +1,28 @@
import { performance } from "node:perf_hooks";
import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import {
buildToolLogMessageData,
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream,
type ToolExecutionEvent,
} from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
export type StreamEvent =
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
| { type: "tool_call"; event: ToolExecutionEvent }
| { type: "delta"; text: string }
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
| { type: "done"; text: string; usage?: StreamUsage }
| { type: "error"; message: string };
function getChatIdOrCreate(chatId?: string) {
@@ -24,44 +32,50 @@ function getChatIdOrCreate(chatId?: string) {
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
const t0 = performance.now();
const chatId = await getChatIdOrCreate(req.chatId);
const shouldPersist = req.persist !== false;
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
const call = await prisma.llmCall.create({
const call =
shouldPersist && chatId
? await prisma.llmCall.create({
data: {
chatId,
provider: req.provider as any,
provider: toPrismaProvider(req.provider) as any,
model: req.model,
request: req as any,
},
select: { id: true },
});
})
: null;
if (shouldPersist && chatId) {
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedProvider: toPrismaProvider(req.provider) as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedProvider: toPrismaProvider(req.provider) as any,
initiatedModel: req.model,
},
}),
]);
}
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
let text = "";
let usage: StreamEvent extends any ? any : never;
let usage: StreamUsage | undefined;
let raw: unknown = { streamed: true };
try {
if (req.provider === "openai" || req.provider === "xai") {
const client = req.provider === "openai" ? openaiClient() : xaiClient();
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const streamEvents =
req.provider === "openai"
? runToolAwareOpenAIChatStream({
@@ -73,7 +87,20 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
logContext: {
provider: req.provider,
model: req.model,
chatId,
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({
@@ -85,7 +112,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
logContext: {
provider: req.provider,
model: req.model,
chatId,
chatId: chatId ?? undefined,
},
});
for await (const ev of streamEvents) {
@@ -96,6 +123,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
}
if (ev.type === "tool_call") {
if (shouldPersist && chatId) {
const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({
data: {
@@ -106,6 +134,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
metadata: toolMessage.metadata as any,
},
});
}
yield { type: "tool_call", event: ev.event };
continue;
}
@@ -156,6 +185,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
const latencyMs = Math.round(performance.now() - t0);
if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
@@ -171,10 +201,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
},
});
});
}
yield { type: "done", text, usage };
} catch (e: any) {
const latencyMs = Math.round(performance.now() - t0);
if (shouldPersist && call) {
await prisma.llmCall.update({
where: { id: call.id },
data: {
@@ -182,6 +214,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
latencyMs,
},
});
}
yield { type: "error", message: e?.message ?? String(e) };
}
}

View File

@@ -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 = {
kind: "image";
@@ -30,6 +32,7 @@ export type ChatMessage = {
export type MultiplexRequest = {
chatId?: string;
persist?: boolean;
provider: Provider;
model: string;
messages: ChatMessage[];

View File

@@ -1,17 +1,21 @@
import { performance } from "node:perf_hooks";
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 { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.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 { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
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[]) {
if (!attachments?.length) return metadata as any;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
@@ -293,6 +317,246 @@ function buildSseHeaders(originHeader: string | undefined) {
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) {
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
@@ -306,6 +570,14 @@ export async function registerRoutes(app: FastifyInstance) {
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) => {
requireAdmin(req);
const chats = await prisma.chat.findMany({
@@ -322,15 +594,55 @@ export async function registerRoutes(app: FastifyInstance) {
lastUsedModel: true,
},
});
return { chats };
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
});
app.post("/v1/chats", async (req) => {
requireAdmin(req);
const Body = z.object({ title: z.string().optional() });
const body = Body.parse(req.body ?? {});
const Body = z
.object({
title: z.string().optional(),
provider: ProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
messages: z.array(CompletionMessageSchema).optional(),
})
.superRefine((value, ctx) => {
if (value.provider && !value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "model is required when provider is supplied",
path: ["model"],
});
}
if (!value.provider && value.model) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "provider is required when model is supplied",
path: ["provider"],
});
}
});
const parsed = Body.safeParse(req.body ?? {});
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const chat = await prisma.chat.create({
data: { title: body.title },
data: {
title: body.title,
initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
initiatedModel: body.model,
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model,
messages: body.messages?.length
? {
create: body.messages.map((message) => ({
role: message.role as any,
content: message.content,
name: message.name,
metadata: message.attachments?.length ? ({ attachments: message.attachments } as any) : undefined,
})),
}
: undefined,
},
select: {
id: true,
title: true,
@@ -342,7 +654,7 @@ export async function registerRoutes(app: FastifyInstance) {
lastUsedModel: true,
},
});
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.patch("/v1/chats/:chatId", async (req) => {
@@ -373,7 +685,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/title/suggest", async (req) => {
@@ -398,7 +710,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
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 suggestedRaw = await generateChatTitle(body.content);
@@ -419,7 +731,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.delete("/v1/chats/:chatId", async (req) => {
@@ -539,7 +851,7 @@ export async function registerRoutes(app: FastifyInstance) {
},
});
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/searches/:searchId/run", async (req) => {
@@ -655,162 +967,24 @@ export async function registerRoutes(app: FastifyInstance) {
const query = body.query?.trim() || existing.query?.trim();
if (!query) return app.httpErrors.badRequest("query is required");
const startedAt = performance.now();
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
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);
const existingStream = activeSearchStreams.get(searchId);
if (existingStream) {
return streamActiveRun(req, reply, existingStream);
}
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,
};
const stream = new ActiveSseStream();
activeSearchStreams.set(searchId, stream);
void executeSearchRunStream(searchId, { ...body, query }, stream);
return streamActiveRun(req, reply, stream);
});
send("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);
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.post("/v1/searches/:searchId/run/stream/attach", async (req, reply) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const { searchId } = Params.parse(req.params);
const stream = activeSearchStreams.get(searchId);
if (!stream) return app.httpErrors.notFound("active search stream not found");
return streamActiveRun(req, reply, stream);
});
app.get("/v1/chats/:chatId", async (req) => {
@@ -823,7 +997,7 @@ export async function registerRoutes(app: FastifyInstance) {
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/:chatId/messages", async (req) => {
@@ -838,7 +1012,9 @@ export async function registerRoutes(app: FastifyInstance) {
});
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const msg = await prisma.message.create({
data: {
@@ -853,20 +1029,31 @@ export async function registerRoutes(app: FastifyInstance) {
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.
app.post("/v1/chat-completions", async (req) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
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(),
});
const body = Body.parse(req.body);
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
// ensure chat exists if provided
if (body.chatId) {
@@ -891,16 +1078,9 @@ export async function registerRoutes(app: FastifyInstance) {
app.post("/v1/chat-completions/stream", async (req, reply) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string().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(),
});
const body = Body.parse(req.body);
const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
// ensure chat exists if provided
if (body.chatId) {
@@ -909,26 +1089,28 @@ export async function registerRoutes(app: FastifyInstance) {
}
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) {
if (body.persist !== false && body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
}
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
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)) {
if (ev.type === "meta") send("meta", 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 (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.flushHeaders();
for await (const ev of runMultiplexStream(body)) {
writeSseEvent(reply, mapChatStreamEvent(ev));
}
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
reply.raw.end();
}
return reply;
});
}

View 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" } },
]);
});

View File

@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream,
type ToolAwareStreamingEvent,
} from "../src/llm/chat-tools.js";
async function* streamFrom(events: any[]) {
for (const event of events) {
await Promise.resolve();
yield event;
}
}
async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
const events: ToolAwareStreamingEvent[] = [];
for await (const event of iterable) {
events.push(event);
}
return events;
}
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
const outputMessage = {
id: "msg_1",
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
};
const client = {
responses: {
create: async () =>
streamFrom([
{ type: "response.output_item.added", item: { ...outputMessage, content: [] }, output_index: 0 },
{ type: "response.output_text.delta", delta: "Hel", output_index: 0, content_index: 0 },
{ type: "response.output_text.delta", delta: "lo", output_index: 0, content_index: 0 },
{ type: "response.output_item.done", item: outputMessage, output_index: 0 },
{
type: "response.completed",
response: {
status: "completed",
output_text: "Hello",
output: [outputMessage],
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
},
},
]),
},
};
const events = await collectEvents(
runToolAwareOpenAIChatStream({
client: client as any,
model: "gpt-test",
messages: [{ role: "user", content: "Say hello" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["delta", "delta", "done"]
);
assert.deepEqual(
events.filter((event) => event.type === "delta").map((event) => event.text),
["Hel", "lo"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
});
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
const client = {
chat: {
completions: {
create: async () =>
streamFrom([
{ choices: [{ delta: { role: "assistant" } }] },
{ choices: [{ delta: { content: "Hel" } }] },
{ choices: [{ delta: { content: "lo" } }] },
{
choices: [{ delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
},
]),
},
},
};
const events = await collectEvents(
runToolAwareChatCompletionsStream({
client: client as any,
model: "grok-test",
messages: [{ role: "user", content: "Say hello" }],
})
);
assert.deepEqual(
events.map((event) => event.type),
["delta", "delta", "done"]
);
assert.deepEqual(
events.filter((event) => event.type === "delta").map((event) => event.text),
["Hel", "lo"]
);
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");
});

View 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",
});
});

View File

@@ -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_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_SEARCH_NUM_RESULTS`: results per search run (default: `10`)

View File

@@ -1,6 +1,6 @@
import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
function normalizeBaseUrl(value: string) {
const trimmed = value.trim();

View File

@@ -39,11 +39,13 @@ type ToolLogMetadata = {
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[]> = {
openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
"hermes-agent": ["hermes-agent"],
};
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
@@ -74,6 +76,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
if (provider === "hermes-agent") return "Hermes Agent";
return "";
}
@@ -159,6 +162,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
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) {
if (fallback && options.includes(fallback)) return fallback;
if (preferred && options.includes(preferred)) return preferred;
@@ -202,6 +209,7 @@ async function main() {
openai: null,
anthropic: null,
xai: null,
"hermes-agent": null,
};
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
let errorMessage: string | null = null;
@@ -1257,8 +1265,10 @@ async function main() {
}
function cycleProvider() {
const currentIndex = PROVIDERS.indexOf(provider);
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
const visibleProviders = getVisibleProviders(modelCatalog);
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;
syncModelForProvider();
updateUI();

View File

@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai";
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = {
models: string[];
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ChatSummary = {

View File

@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
- Composer adapts to the active item:
- Chat sends `POST /v1/chat-completions/stream` (SSE).
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
- Keyboard shortcuts:
- `Cmd/Ctrl+J`: start a new chat.
- `Shift+Cmd/Ctrl+J`: start a new search.
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
Client API contract docs:
- `../docs/api/rest.md`

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,16 @@ type Props = {
type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -26,10 +33,26 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
return `Tool failed: ${metadata.error.trim()}`;
}
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
if (message.content.trim()) return message.content.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
const raw = metadata.toolName?.trim() || message.name?.trim();
if (!raw) return "Tool call";
return raw
.replace(/_/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
.join(" ");
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
@@ -37,6 +60,27 @@ function getToolIconName(toolName: string | null | undefined) {
return "generic";
}
function formatDuration(durationMs: unknown) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
return `${Math.round(durationMs)} ms`;
}
function formatToolTimestamp(...values: Array<string | null | undefined>) {
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
if (!value) return null;
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
}
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
return [
isFailed ? "Failed" : "Completed",
formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
]
.filter(Boolean)
.join(" • ");
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
@@ -50,18 +94,39 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const isFailed = toolLogMetadata.status === "failed";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
return (
<div key={message.id} className="flex justify-start">
<div
className={cn(
"inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
)}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
<span>{getToolSummary(message, toolLogMetadata)}</span>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
{toolSummary}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useMemo } from "preact/hooks";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { marked, Renderer } from "marked";
import { cn } from "@/lib/utils";
type MarkdownMode = "default" | "citationTokens";
@@ -21,8 +21,15 @@ function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitatio
});
}
const markdownRenderer = new Renderer();
const renderTable = markdownRenderer.table.bind(markdownRenderer);
markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
};
function renderMarkdown(markdown: string) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true }) as string;
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
}

View File

@@ -0,0 +1,31 @@
import { useEffect } from "preact/hooks";
import { cn } from "@/lib/utils";
const CHARACTER_IDLE_SRC = "/character-idle.gif";
const CHARACTER_BUSY_SRC = "/character-busy.gif";
type SybilCharacterProps = {
className?: string;
isBusy?: boolean;
};
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
useEffect(() => {
const busyImage = new Image();
busyImage.src = CHARACTER_BUSY_SRC;
}, []);
return (
<img
aria-hidden="true"
alt=""
className={cn(
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
className
)}
data-state={isBusy ? "busy" : "idle"}
draggable={false}
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
/>
);
}

View File

@@ -4,6 +4,14 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "StalinistOne";
src: url("/StalinistOne-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--background: 235 45% 4%;
@@ -57,8 +65,8 @@ textarea {
}
.sybil-wordmark {
font-family: "Orbitron", "Inter", sans-serif;
font-weight: 900;
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
font-weight: 400;
letter-spacing: 0;
line-height: 1;
}
@@ -83,6 +91,77 @@ textarea {
word-break: break-word;
}
.md-table-scroll {
max-width: 100%;
margin: 0.35rem 0 1rem;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
.md-content table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.94em;
line-height: 1.48;
}
.md-table-scroll::-webkit-scrollbar {
height: 0.45rem;
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: hsl(263 78% 72% / 0.34);
}
.md-content th,
.md-content td {
padding: 0.48rem 0.7rem;
border-right: 1px solid hsl(var(--border) / 0.72);
border-bottom: 1px solid hsl(var(--border) / 0.7);
text-align: left;
vertical-align: top;
word-break: normal;
}
.md-content th:last-child,
.md-content td:last-child {
border-right: 0;
}
.md-content tr:last-child td {
border-bottom: 0;
}
.md-content th {
background: hsl(251 40% 15% / 0.92);
color: hsl(258 36% 98%);
font-weight: 700;
white-space: nowrap;
}
.md-content td {
color: hsl(258 34% 94% / 0.96);
}
.md-content tbody tr:nth-child(odd) td {
background: hsl(242 32% 10% / 0.58);
}
.md-content tbody tr:nth-child(even) td {
background: hsl(252 36% 13% / 0.46);
}
.md-content tbody tr:hover td {
background: hsl(263 46% 20% / 0.48);
}
.md-content p + p {
margin-top: 0.85rem;
}
@@ -113,7 +192,13 @@ textarea {
margin-top: 0.65rem;
margin-left: 0;
padding-left: 0;
list-style-position: inside;
list-style: none;
}
.md-content li > ul,
.md-content li > ol {
margin-top: 0.3rem;
padding-left: 1.35rem;
}
.md-content li + li {
@@ -121,17 +206,31 @@ textarea {
}
.md-content code {
background: hsl(288 22% 23%);
border-radius: 0.25rem;
background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem;
padding: 0.05rem 0.3rem;
font-size: 0.86em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.md-content pre {
overflow-x: auto;
border-radius: 0.5rem;
background: hsl(287 28% 13%);
padding: 0.6rem 0.75rem;
border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem;
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 {

View File

@@ -127,7 +127,7 @@ export type CompletionRequestMessage = {
attachments?: ChatAttachment[];
};
export type Provider = "openai" | "anthropic" | "xai";
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = {
models: string[];
@@ -136,7 +136,12 @@ export type ProviderModelInfo = {
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ActiveRunsResponse = {
chats: string[];
searches: string[];
};
type CompletionResponse = {
@@ -148,13 +153,20 @@ type CompletionResponse = {
};
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
};
type CreateChatRequest = {
title?: string;
provider?: Provider;
model?: string;
messages?: CompletionRequestMessage[];
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN;
@@ -210,10 +222,15 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function createChat(title?: string) {
export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs");
}
export async function createChat(input?: string | CreateChatRequest) {
const body = typeof input === "string" ? { title: input } : input ?? {};
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: JSON.stringify({ title }),
body: JSON.stringify(body),
});
return data.chat;
}
@@ -325,6 +342,85 @@ type RunSearchStreamHandlers = {
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(
searchId: string,
body: SearchRunRequest,
@@ -429,6 +525,30 @@ export async function runSearchStream(
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: {
chatId: string;
provider: Provider;
@@ -443,7 +563,8 @@ export async function runCompletion(body: {
export async function runCompletionStream(
body: {
chatId: string;
chatId?: string | null;
persist?: boolean;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
@@ -547,3 +668,26 @@ export async function runCompletionStream(
}
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);
});
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}