Compare commits
14 Commits
195e157e1a
...
codex/syst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bb8503aa | ||
|
|
93e34d086f | ||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 |
@@ -12,6 +12,9 @@ services:
|
|||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||||
|
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||||
|
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||||
|
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||||
EXA_API_KEY: ${EXA_API_KEY:-}
|
EXA_API_KEY: ${EXA_API_KEY:-}
|
||||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||||
|
|||||||
@@ -33,11 +33,14 @@ Chat upload limits:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }
|
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||||
|
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||||
|
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||||
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
|
|
||||||
@@ -55,6 +58,42 @@ Behavior notes:
|
|||||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||||
- The lists are not durable across server restarts.
|
- The lists are not durable across server restarts.
|
||||||
|
|
||||||
|
## Workspace Items
|
||||||
|
|
||||||
|
### `GET /v1/workspace-items`
|
||||||
|
- Response: `{ "items": WorkspaceItem[] }`
|
||||||
|
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "chat",
|
||||||
|
"id": "chat-id",
|
||||||
|
"title": "optional title",
|
||||||
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"initiatedProvider": "openai",
|
||||||
|
"initiatedModel": "gpt-4.1-mini",
|
||||||
|
"lastUsedProvider": "openai",
|
||||||
|
"lastUsedModel": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "search",
|
||||||
|
"id": "search-id",
|
||||||
|
"title": "optional title",
|
||||||
|
"query": "search query",
|
||||||
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-14T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||||
|
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
|
- The response currently combines up to 100 chats and up to 100 searches.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
### `GET /v1/chats`
|
### `GET /v1/chats`
|
||||||
@@ -65,7 +104,7 @@ Behavior notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai",
|
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -152,7 +191,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -206,11 +245,12 @@ Behavior notes:
|
|||||||
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||||
|
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||||
- For `xai`, image attachments are sent as Chat Completions content parts alongside text.
|
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||||
- Available tool calls for chat: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
||||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||||
@@ -311,9 +351,9 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"initiatedProvider": "openai|anthropic|xai|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null"
|
"lastUsedModel": "string|null"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -359,9 +399,9 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"initiatedProvider": "openai|anthropic|xai|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"messages": [Message]
|
"messages": [Message]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Authentication:
|
|||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai",
|
"provider": "openai|anthropic|xai|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -152,8 +152,9 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
|
|
||||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||||
|
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||||
- `xai`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
- xAI: `grok-3-mini`
|
- xAI: `grok-3-mini`
|
||||||
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
17
ios/Apps/Sybil/Info.plist
Normal file
17
ios/Apps/Sybil/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>net.buzzert.sybil2.quick-question</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Quick question</string>
|
||||||
|
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||||
|
<string>sparkles</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -5,6 +5,8 @@ import UIKit
|
|||||||
@main
|
@main
|
||||||
struct SybilApp: App
|
struct SybilApp: App
|
||||||
{
|
{
|
||||||
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
SplitView()
|
SplitView()
|
||||||
@@ -14,3 +16,79 @@ struct SybilApp: App
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
|
options: UIScene.ConnectionOptions
|
||||||
|
) -> UISceneConfiguration {
|
||||||
|
let configuration = UISceneConfiguration(
|
||||||
|
name: "Default Configuration",
|
||||||
|
sessionRole: connectingSceneSession.role
|
||||||
|
)
|
||||||
|
configuration.delegateClass = SybilSceneDelegate.self
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
completionHandler: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||||
|
func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowScene(
|
||||||
|
_ windowScene: UIWindowScene,
|
||||||
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
completionHandler: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum SybilHomeScreenQuickActionHandler {
|
||||||
|
static func configureQuickActions() {
|
||||||
|
// The quick question action is static in Info.plist so it is available before first launch.
|
||||||
|
UIApplication.shared.shortcutItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||||
|
guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ targets:
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.5
|
MARKETING_VERSION: 1.9
|
||||||
CURRENT_PROJECT_VERSION: 6
|
CURRENT_PROJECT_VERSION: 10
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct SplitView: View {
|
public struct SplitView: View {
|
||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
|
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
|
@State private var quickQuestionFocusRequest = 0
|
||||||
|
@State private var hasPendingQuickQuestionPresentation = false
|
||||||
|
@State private var isQuickQuestionPresented = false
|
||||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||||
|
|
||||||
private var keyboardActions: SybilKeyboardActions? {
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
@@ -74,8 +78,28 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
|
SybilQuickQuestionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
focusRequest: quickQuestionFocusRequest
|
||||||
|
)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
||||||
|
guard request > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueQuickQuestionPresentation()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
@@ -112,6 +136,28 @@ public struct SplitView: View {
|
|||||||
columnVisibility = .all
|
columnVisibility = .all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func queueQuickQuestionPresentation() {
|
||||||
|
hasPendingQuickQuestionPresentation = true
|
||||||
|
presentPendingQuickQuestionIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPendingQuickQuestionIfPossible() {
|
||||||
|
guard hasPendingQuickQuestionPresentation,
|
||||||
|
!viewModel.isCheckingSession,
|
||||||
|
viewModel.isAuthenticated
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
quickQuestionFocusRequest += 1
|
||||||
|
isQuickQuestionPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleQuickQuestionDismissed() {
|
||||||
|
viewModel.cancelQuickQuestion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
|||||||
@@ -44,16 +44,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||||
|
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
|
||||||
|
return response.items
|
||||||
|
}
|
||||||
|
|
||||||
func listChats() async throws -> [ChatSummary] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||||
return response.chats
|
return response.chats
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String? = nil) async throws -> ChatSummary {
|
func createChat(
|
||||||
|
title: String? = nil,
|
||||||
|
provider: Provider? = nil,
|
||||||
|
model: String? = nil,
|
||||||
|
messages: [CompletionRequestMessage]? = nil
|
||||||
|
) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats",
|
"/v1/chats",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: AnyEncodable(ChatCreateBody(title: title)),
|
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
|
||||||
responseType: ChatCreateResponse.self
|
responseType: ChatCreateResponse.self
|
||||||
)
|
)
|
||||||
return response.chat
|
return response.chat
|
||||||
@@ -617,13 +627,18 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
|
|
||||||
struct CompletionStreamRequest: Codable, Sendable {
|
struct CompletionStreamRequest: Codable, Sendable {
|
||||||
var chatId: String?
|
var chatId: String?
|
||||||
|
var persist: Bool? = nil
|
||||||
var provider: Provider
|
var provider: Provider
|
||||||
var model: String
|
var model: String
|
||||||
var messages: [CompletionRequestMessage]
|
var messages: [CompletionRequestMessage]
|
||||||
|
var userLocation: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
|
var provider: Provider?
|
||||||
|
var model: String?
|
||||||
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
|
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||||
func listChats() async throws -> [ChatSummary]
|
func listChats() async throws -> [ChatSummary]
|
||||||
func createChat(title: String?) async throws -> ChatSummary
|
func createChat(
|
||||||
|
title: String?,
|
||||||
|
provider: Provider?,
|
||||||
|
model: String?,
|
||||||
|
messages: [CompletionRequestMessage]?
|
||||||
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
@@ -32,3 +38,9 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SybilAPIClienting {
|
||||||
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
|
try await createChat(title: title, provider: nil, model: nil, messages: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,18 +17,6 @@ struct SybilChatTranscriptView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
if isSending && !hasPendingAssistant {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(SybilTheme.textMuted)
|
|
||||||
Text("Assistant is typing…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(messages.reversed()) { message in
|
ForEach(messages.reversed()) { message in
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case openai
|
||||||
case anthropic
|
case anthropic
|
||||||
case xai
|
case xai
|
||||||
|
case hermesAgent = "hermes-agent"
|
||||||
|
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openai: return "OpenAI"
|
case .openai: return "OpenAI"
|
||||||
case .anthropic: return "Anthropic"
|
case .anthropic: return "Anthropic"
|
||||||
case .xai: return "xAI"
|
case .xai: return "xAI"
|
||||||
|
case .hermesAgent: return "Hermes Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,75 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
||||||
|
case chat
|
||||||
|
case search
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var type: WorkspaceItemType
|
||||||
|
public var id: String
|
||||||
|
public var title: String?
|
||||||
|
public var query: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
public var initiatedProvider: Provider?
|
||||||
|
public var initiatedModel: String?
|
||||||
|
public var lastUsedProvider: Provider?
|
||||||
|
public var lastUsedModel: String?
|
||||||
|
|
||||||
|
public init(chat: ChatSummary) {
|
||||||
|
self.type = .chat
|
||||||
|
self.id = chat.id
|
||||||
|
self.title = chat.title
|
||||||
|
self.query = nil
|
||||||
|
self.createdAt = chat.createdAt
|
||||||
|
self.updatedAt = chat.updatedAt
|
||||||
|
self.initiatedProvider = chat.initiatedProvider
|
||||||
|
self.initiatedModel = chat.initiatedModel
|
||||||
|
self.lastUsedProvider = chat.lastUsedProvider
|
||||||
|
self.lastUsedModel = chat.lastUsedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(search: SearchSummary) {
|
||||||
|
self.type = .search
|
||||||
|
self.id = search.id
|
||||||
|
self.title = search.title
|
||||||
|
self.query = search.query
|
||||||
|
self.createdAt = search.createdAt
|
||||||
|
self.updatedAt = search.updatedAt
|
||||||
|
self.initiatedProvider = nil
|
||||||
|
self.initiatedModel = nil
|
||||||
|
self.lastUsedProvider = nil
|
||||||
|
self.lastUsedModel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var chatSummary: ChatSummary? {
|
||||||
|
guard type == .chat else { return nil }
|
||||||
|
return ChatSummary(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
initiatedProvider: initiatedProvider,
|
||||||
|
initiatedModel: initiatedModel,
|
||||||
|
lastUsedProvider: lastUsedProvider,
|
||||||
|
lastUsedModel: lastUsedModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var searchSummary: SearchSummary? {
|
||||||
|
guard type == .search else { return nil }
|
||||||
|
return SearchSummary(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
query: query,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -404,8 +475,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct CompletionStreamMeta: Codable, Sendable {
|
public struct CompletionStreamMeta: Codable, Sendable {
|
||||||
public var chatId: String
|
public var chatId: String?
|
||||||
public var callId: String
|
public var callId: String?
|
||||||
public var provider: Provider
|
public var provider: Provider
|
||||||
public var model: String
|
public var model: String
|
||||||
}
|
}
|
||||||
@@ -522,6 +593,10 @@ struct SearchListResponse: Codable {
|
|||||||
var searches: [SearchSummary]
|
var searches: [SearchSummary]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceListResponse: Codable {
|
||||||
|
var items: [WorkspaceItem]
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatDetailResponse: Codable {
|
struct ChatDetailResponse: Codable {
|
||||||
var chat: ChatDetail
|
var chat: ChatDetail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal 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")."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ final class SybilSettingsStore {
|
|||||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
|
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||||
|
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||||
|
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||||
|
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||||
|
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||||
|
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
|
|||||||
var adminToken: String
|
var adminToken: String
|
||||||
var preferredProvider: Provider
|
var preferredProvider: Provider
|
||||||
var preferredModelByProvider: [Provider: String]
|
var preferredModelByProvider: [Provider: String]
|
||||||
|
var quickQuestionPreferredProvider: Provider
|
||||||
|
var quickQuestionPreferredModelByProvider: [Provider: String]
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
|
|||||||
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.preferredProvider = provider
|
self.preferredProvider = provider
|
||||||
|
|
||||||
self.preferredModelByProvider = [
|
let preferredModels: [Provider: String] = [
|
||||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
|
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||||
|
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||||
|
]
|
||||||
|
self.preferredModelByProvider = preferredModels
|
||||||
|
|
||||||
|
self.quickQuestionPreferredProvider =
|
||||||
|
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||||
|
self.quickQuestionPreferredModelByProvider = [
|
||||||
|
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||||
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
|
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||||
|
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +72,13 @@ final class SybilSettingsStore {
|
|||||||
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
|
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||||
|
|
||||||
|
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trimmedTokenOrNil: String? {
|
var trimmedTokenOrNil: String? {
|
||||||
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
|
|||||||
raw.removeLast()
|
raw.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard var components = URLComponents(string: raw) else {
|
guard let components = URLComponents(string: raw) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
var chats: [ChatSummary] = []
|
var chats: [ChatSummary] = []
|
||||||
var searches: [SearchSummary] = []
|
var searches: [SearchSummary] = []
|
||||||
|
var workspaceItems: [WorkspaceItem] = []
|
||||||
|
|
||||||
var selectedItem: SidebarSelection?
|
var selectedItem: SidebarSelection?
|
||||||
var selectedChat: ChatDetail?
|
var selectedChat: ChatDetail?
|
||||||
@@ -111,6 +112,16 @@ final class SybilViewModel {
|
|||||||
var provider: Provider
|
var provider: Provider
|
||||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||||
var model: String
|
var model: String
|
||||||
|
var quickQuestionPrompt = ""
|
||||||
|
var quickQuestionMessages: [Message] = []
|
||||||
|
var quickQuestionError: String?
|
||||||
|
var quickQuestionProvider: Provider
|
||||||
|
var quickQuestionModel: String
|
||||||
|
var quickQuestionSubmittedPrompt: String?
|
||||||
|
var quickQuestionSubmittedProvider: Provider?
|
||||||
|
var quickQuestionSubmittedModel: String?
|
||||||
|
var isQuickQuestionSending = false
|
||||||
|
var isConvertingQuickQuestion = false
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
@@ -132,6 +143,10 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
|
private var quickQuestionTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored
|
||||||
|
private var quickQuestionRunID: UUID?
|
||||||
|
@ObservationIgnored
|
||||||
private var isAppActive = true
|
private var isAppActive = true
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var appLifecycleGeneration = 0
|
private var appLifecycleGeneration = 0
|
||||||
@@ -141,7 +156,8 @@ final class SybilViewModel {
|
|||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"]
|
.xai: ["grok-3-mini"],
|
||||||
|
.hermesAgent: ["hermes-agent"]
|
||||||
]
|
]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -152,14 +168,56 @@ final class SybilViewModel {
|
|||||||
) {
|
) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.clientFactory = clientFactory
|
self.clientFactory = clientFactory
|
||||||
self.provider = settings.preferredProvider
|
let initialProvider = settings.preferredProvider
|
||||||
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
|
||||||
|
self.provider = initialProvider
|
||||||
|
self.model = initialModel
|
||||||
|
let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
|
||||||
|
let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
|
||||||
|
self.quickQuestionProvider = initialQuickQuestionProvider
|
||||||
|
self.quickQuestionModel = initialQuickQuestionModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerModelOptions: [String] {
|
var providerModelOptions: [String] {
|
||||||
modelOptions(for: provider)
|
modelOptions(for: provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var providerOptions: [Provider] {
|
||||||
|
Provider.allCases.filter { candidate in
|
||||||
|
candidate != .hermesAgent || modelCatalog[candidate] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var quickQuestionProviderModelOptions: [String] {
|
||||||
|
modelOptions(for: quickQuestionProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
var canSendQuickQuestion: Bool {
|
||||||
|
!isQuickQuestionSending &&
|
||||||
|
!isConvertingQuickQuestion &&
|
||||||
|
!quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
!quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var quickQuestionAnswerText: String {
|
||||||
|
for message in quickQuestionMessages.reversed() where message.role == .assistant {
|
||||||
|
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !content.isEmpty {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var canConvertQuickQuestion: Bool {
|
||||||
|
!isQuickQuestionSending &&
|
||||||
|
!isConvertingQuickQuestion &&
|
||||||
|
!(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
||||||
|
!quickQuestionAnswerText.isEmpty &&
|
||||||
|
quickQuestionSubmittedProvider != nil &&
|
||||||
|
!(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
func modelOptions(for candidate: Provider) -> [String] {
|
func modelOptions(for candidate: Provider) -> [String] {
|
||||||
let serverModels = modelCatalog[candidate]?.models ?? []
|
let serverModels = modelCatalog[candidate]?.models ?? []
|
||||||
if !serverModels.isEmpty {
|
if !serverModels.isEmpty {
|
||||||
@@ -331,40 +389,40 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
let chatItems: [SidebarItem] = chats.map { chat in
|
workspaceItems.map { item in
|
||||||
let initiatedLabel: String?
|
switch item.type {
|
||||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
case .chat:
|
||||||
if let provider = chat.initiatedProvider {
|
let initiatedLabel: String?
|
||||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||||
|
if let provider = item.initiatedProvider {
|
||||||
|
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||||
|
} else {
|
||||||
|
initiatedLabel = model
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
initiatedLabel = model
|
initiatedLabel = nil
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
initiatedLabel = nil
|
return SidebarItem(
|
||||||
|
selection: .chat(item.id),
|
||||||
|
kind: .chat,
|
||||||
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
initiatedLabel: initiatedLabel,
|
||||||
|
isRunning: isChatRowRunning(item.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .search:
|
||||||
|
return SidebarItem(
|
||||||
|
selection: .search(item.id),
|
||||||
|
kind: .search,
|
||||||
|
title: searchTitle(title: item.title, query: item.query),
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
initiatedLabel: "exa",
|
||||||
|
isRunning: isSearchRowRunning(item.id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return SidebarItem(
|
|
||||||
selection: .chat(chat.id),
|
|
||||||
kind: .chat,
|
|
||||||
title: chatTitle(title: chat.title, messages: nil),
|
|
||||||
updatedAt: chat.updatedAt,
|
|
||||||
initiatedLabel: initiatedLabel,
|
|
||||||
isRunning: isChatRowRunning(chat.id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchItems: [SidebarItem] = searches.map { search in
|
|
||||||
SidebarItem(
|
|
||||||
selection: .search(search.id),
|
|
||||||
kind: .search,
|
|
||||||
title: searchTitle(title: search.title, query: search.query),
|
|
||||||
updatedAt: search.updatedAt,
|
|
||||||
initiatedLabel: "exa",
|
|
||||||
isRunning: isSearchRowRunning(search.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedChatSummary: ChatSummary? {
|
var selectedChatSummary: ChatSummary? {
|
||||||
@@ -415,6 +473,7 @@ final class SybilViewModel {
|
|||||||
localActiveSearchIDs = []
|
localActiveSearchIDs = []
|
||||||
serverActiveChatIDs = []
|
serverActiveChatIDs = []
|
||||||
serverActiveSearchIDs = []
|
serverActiveSearchIDs = []
|
||||||
|
resetQuickQuestion()
|
||||||
draftIdentity = UUID()
|
draftIdentity = UUID()
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
settings.persist()
|
settings.persist()
|
||||||
@@ -444,6 +503,7 @@ final class SybilViewModel {
|
|||||||
authMode = nil
|
authMode = nil
|
||||||
chats = []
|
chats = []
|
||||||
searches = []
|
searches = []
|
||||||
|
workspaceItems = []
|
||||||
selectedItem = .settings
|
selectedItem = .settings
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
@@ -487,6 +547,160 @@ final class SybilViewModel {
|
|||||||
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
|
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setQuickQuestionProvider(_ nextProvider: Provider) {
|
||||||
|
quickQuestionProvider = nextProvider
|
||||||
|
|
||||||
|
let options = modelOptions(for: nextProvider)
|
||||||
|
if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
|
||||||
|
quickQuestionModel = preferred
|
||||||
|
} else if let first = options.first {
|
||||||
|
quickQuestionModel = first
|
||||||
|
} else {
|
||||||
|
quickQuestionModel = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
persistQuickQuestionModelSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setQuickQuestionModel(_ nextModel: String) {
|
||||||
|
quickQuestionModel = nextModel
|
||||||
|
persistQuickQuestionModelSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistQuickQuestionModelSelection() {
|
||||||
|
settings.quickQuestionPreferredProvider = quickQuestionProvider
|
||||||
|
let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedModel.isEmpty {
|
||||||
|
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
|
||||||
|
}
|
||||||
|
settings.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateQuickQuestionPrompt(_ nextPrompt: String) {
|
||||||
|
guard nextPrompt != quickQuestionPrompt else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
|
||||||
|
cancelQuickQuestion()
|
||||||
|
quickQuestionSubmittedPrompt = nil
|
||||||
|
quickQuestionSubmittedProvider = nil
|
||||||
|
quickQuestionSubmittedModel = nil
|
||||||
|
quickQuestionMessages = []
|
||||||
|
quickQuestionError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quickQuestionPrompt = nextPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetQuickQuestion() {
|
||||||
|
cancelQuickQuestion()
|
||||||
|
quickQuestionPrompt = ""
|
||||||
|
quickQuestionMessages = []
|
||||||
|
quickQuestionError = nil
|
||||||
|
quickQuestionSubmittedPrompt = nil
|
||||||
|
quickQuestionSubmittedProvider = nil
|
||||||
|
quickQuestionSubmittedModel = nil
|
||||||
|
isConvertingQuickQuestion = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelQuickQuestion() {
|
||||||
|
quickQuestionTask?.cancel()
|
||||||
|
quickQuestionTask = nil
|
||||||
|
quickQuestionRunID = nil
|
||||||
|
isQuickQuestionSending = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func sendQuickQuestion() -> Task<Void, Never>? {
|
||||||
|
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !selectedModel.isEmpty else {
|
||||||
|
quickQuestionError = "No model available for selected provider."
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelQuickQuestion()
|
||||||
|
let selectedProvider = quickQuestionProvider
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||||
|
}
|
||||||
|
quickQuestionTask = task
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func convertQuickQuestionToChat() async -> Bool {
|
||||||
|
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let answer = quickQuestionAnswerText
|
||||||
|
guard !question.isEmpty,
|
||||||
|
!answer.isEmpty,
|
||||||
|
let submittedProvider = quickQuestionSubmittedProvider,
|
||||||
|
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!submittedModel.isEmpty,
|
||||||
|
!isQuickQuestionSending,
|
||||||
|
!isConvertingQuickQuestion
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isConvertingQuickQuestion = true
|
||||||
|
quickQuestionError = nil
|
||||||
|
defer {
|
||||||
|
isConvertingQuickQuestion = false
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
|
||||||
|
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
|
||||||
|
let chat = try await client().createChat(
|
||||||
|
title: title.isEmpty ? "Quick question" : title,
|
||||||
|
provider: submittedProvider,
|
||||||
|
model: submittedModel,
|
||||||
|
messages: [
|
||||||
|
CompletionRequestMessage(role: .user, content: question),
|
||||||
|
CompletionRequestMessage(role: .assistant, content: answer)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
setProvider(submittedProvider, model: submittedModel)
|
||||||
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
|
draftKind = nil
|
||||||
|
selectedItem = .chat(chat.id)
|
||||||
|
selectedChat = ChatDetail(
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title,
|
||||||
|
createdAt: chat.createdAt,
|
||||||
|
updatedAt: chat.updatedAt,
|
||||||
|
initiatedProvider: chat.initiatedProvider,
|
||||||
|
initiatedModel: chat.initiatedModel,
|
||||||
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
messages: []
|
||||||
|
)
|
||||||
|
selectedSearch = nil
|
||||||
|
composer = ""
|
||||||
|
composerAttachments = []
|
||||||
|
|
||||||
|
await refreshCollections(preferredSelection: .chat(chat.id))
|
||||||
|
resetQuickQuestion()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
quickQuestionError = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startNewChat() {
|
func startNewChat() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||||
resetSelectionLoading()
|
resetSelectionLoading()
|
||||||
@@ -700,6 +914,23 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||||
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.ui,
|
||||||
|
"Sidebar pull-to-refresh requested"
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferredSelection = selectedItem
|
||||||
|
let refreshTask = Task { @MainActor in
|
||||||
|
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
|
||||||
|
}
|
||||||
|
await refreshTask.value
|
||||||
|
}
|
||||||
|
|
||||||
func sendComposer() async {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let attachments = composerAttachments
|
let attachments = composerAttachments
|
||||||
@@ -806,6 +1037,7 @@ final class SybilViewModel {
|
|||||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
isCreatingSearchChat = false
|
isCreatingSearchChat = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -817,6 +1049,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
|
|
||||||
selectedItem = .chat(chat.id)
|
selectedItem = .chat(chat.id)
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
@@ -830,23 +1063,106 @@ final class SybilViewModel {
|
|||||||
isCreatingSearchChat = false
|
isCreatingSearchChat = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
|
||||||
|
let runID = UUID()
|
||||||
|
quickQuestionRunID = runID
|
||||||
|
quickQuestionError = nil
|
||||||
|
quickQuestionSubmittedPrompt = prompt
|
||||||
|
quickQuestionSubmittedProvider = provider
|
||||||
|
quickQuestionSubmittedModel = model
|
||||||
|
quickQuestionMessages = [
|
||||||
|
Message(
|
||||||
|
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||||
|
createdAt: Date(),
|
||||||
|
role: .assistant,
|
||||||
|
content: "",
|
||||||
|
name: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
isQuickQuestionSending = true
|
||||||
|
|
||||||
|
defer {
|
||||||
|
if quickQuestionRunID == runID {
|
||||||
|
quickQuestionTask = nil
|
||||||
|
quickQuestionRunID = nil
|
||||||
|
isQuickQuestionSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await client().runCompletionStream(
|
||||||
|
body: CompletionStreamRequest(
|
||||||
|
chatId: nil,
|
||||||
|
persist: false,
|
||||||
|
provider: provider,
|
||||||
|
model: model,
|
||||||
|
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
||||||
|
)
|
||||||
|
) { [weak self] event in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let streamError = await streamStatus.error() {
|
||||||
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard quickQuestionRunID == runID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isCancellation(error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickQuestionError = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
|
||||||
|
switch event {
|
||||||
|
case .meta:
|
||||||
|
break
|
||||||
|
|
||||||
|
case let .toolCall(payload):
|
||||||
|
insertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
|
case let .delta(payload):
|
||||||
|
guard !payload.text.isEmpty else { return }
|
||||||
|
mutateQuickQuestionAssistantMessage { existing in
|
||||||
|
existing + payload.text
|
||||||
|
}
|
||||||
|
|
||||||
|
case let .done(payload):
|
||||||
|
mutateQuickQuestionAssistantMessage { _ in
|
||||||
|
payload.text
|
||||||
|
}
|
||||||
|
|
||||||
|
case let .error(payload):
|
||||||
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
|
case .ignored:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadInitialData(using client: any SybilAPIClienting) async {
|
private func loadInitialData(using client: any SybilAPIClienting) async {
|
||||||
isLoadingCollections = true
|
isLoadingCollections = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let chatsValue = client.listChats()
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||||
async let searchesValue = client.listSearches()
|
|
||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
chats = nextChats
|
applyWorkspaceItems(nextWorkspaceItems)
|
||||||
searches = nextSearches
|
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -863,7 +1179,7 @@ final class SybilViewModel {
|
|||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
nextSelection = .settings
|
nextSelection = .settings
|
||||||
} else if let currentSelection = selectedItem,
|
} else if let currentSelection = selectedItem,
|
||||||
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) {
|
hasSelection(currentSelection, chats: chats, searches: searches) {
|
||||||
nextSelection = currentSelection
|
nextSelection = currentSelection
|
||||||
} else {
|
} else {
|
||||||
nextSelection = sidebarItems.first?.selection
|
nextSelection = sidebarItems.first?.selection
|
||||||
@@ -893,6 +1209,11 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func syncModelSelectionWithServerCatalog() {
|
private func syncModelSelectionWithServerCatalog() {
|
||||||
|
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
|
||||||
|
provider = firstProvider
|
||||||
|
settings.preferredProvider = firstProvider
|
||||||
|
}
|
||||||
|
|
||||||
if !providerModelOptions.contains(model), let first = providerModelOptions.first {
|
if !providerModelOptions.contains(model), let first = providerModelOptions.first {
|
||||||
model = first
|
model = first
|
||||||
settings.preferredModelByProvider[provider] = first
|
settings.preferredModelByProvider[provider] = first
|
||||||
@@ -902,6 +1223,22 @@ final class SybilViewModel {
|
|||||||
model = preferred
|
model = preferred
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
|
||||||
|
quickQuestionProvider = firstProvider
|
||||||
|
settings.quickQuestionPreferredProvider = firstProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
|
||||||
|
quickQuestionModel = first
|
||||||
|
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
|
||||||
|
quickQuestionProviderModelOptions.contains(preferred)
|
||||||
|
{
|
||||||
|
quickQuestionModel = preferred
|
||||||
|
}
|
||||||
|
|
||||||
settings.persist()
|
settings.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,18 +1251,16 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
async let chatsValue = client.listChats()
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||||
async let searchesValue = client.listSearches()
|
|
||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
chats = nextChats
|
applyWorkspaceItems(nextWorkspaceItems)
|
||||||
searches = nextSearches
|
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
||||||
)
|
)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
@@ -943,10 +1278,10 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let preferredSelection,
|
if let preferredSelection,
|
||||||
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
||||||
selectedItem = preferredSelection
|
selectedItem = preferredSelection
|
||||||
} else if let existing = selectedItem,
|
} else if let existing = selectedItem,
|
||||||
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
hasSelection(existing, chats: chats, searches: searches) {
|
||||||
selectedItem = existing
|
selectedItem = existing
|
||||||
} else {
|
} else {
|
||||||
selectedItem = sidebarItems.first?.selection
|
selectedItem = sidebarItems.first?.selection
|
||||||
@@ -959,7 +1294,9 @@ final class SybilViewModel {
|
|||||||
attachToVisibleActiveRunIfNeeded()
|
attachToVisibleActiveRunIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if shouldSuppressInactiveTransportError(error) {
|
if isCancellation(error) {
|
||||||
|
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
|
||||||
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||||
} else {
|
} else {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
@@ -1038,6 +1375,34 @@ final class SybilViewModel {
|
|||||||
serverActiveSearchIDs = Set(activeRuns.searches)
|
serverActiveSearchIDs = Set(activeRuns.searches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
|
||||||
|
workspaceItems = items
|
||||||
|
chats = items.compactMap(\.chatSummary)
|
||||||
|
searches = items.compactMap(\.searchSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
||||||
|
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
|
||||||
|
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
||||||
|
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||||
|
workspaceItems.remove(at: existingIndex)
|
||||||
|
if moveToFront {
|
||||||
|
workspaceItems.insert(item, at: 0)
|
||||||
|
} else {
|
||||||
|
workspaceItems.insert(item, at: existingIndex)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceItems.insert(item, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
private func attachToVisibleActiveRunIfNeeded() {
|
private func attachToVisibleActiveRunIfNeeded() {
|
||||||
guard draftKind == nil else {
|
guard draftKind == nil else {
|
||||||
return
|
return
|
||||||
@@ -1369,6 +1734,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
chats.removeAll(where: { $0.id == created.id })
|
chats.removeAll(where: { $0.id == created.id })
|
||||||
chats.insert(created, at: 0)
|
chats.insert(created, at: 0)
|
||||||
|
upsertWorkspaceChat(created)
|
||||||
|
|
||||||
if shouldShowCreatedChat {
|
if shouldShowCreatedChat {
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
@@ -1445,6 +1811,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
self.upsertWorkspaceChat(updated, moveToFront: false)
|
||||||
|
|
||||||
if self.selectedChat?.id == updated.id {
|
if self.selectedChat?.id == updated.id {
|
||||||
self.selectedChat?.title = updated.title
|
self.selectedChat?.title = updated.title
|
||||||
@@ -1582,6 +1949,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
searches.removeAll(where: { $0.id == created.id })
|
searches.removeAll(where: { $0.id == created.id })
|
||||||
searches.insert(created, at: 0)
|
searches.insert(created, at: 0)
|
||||||
|
upsertWorkspaceSearch(created)
|
||||||
|
|
||||||
if shouldShowCreatedSearch {
|
if shouldShowCreatedSearch {
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
@@ -1752,6 +2120,15 @@ final class SybilViewModel {
|
|||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
||||||
|
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
||||||
|
guard let index else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||||
|
}
|
||||||
|
|
||||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
guard var pending = pendingChatStates[chatID] else {
|
guard var pending = pendingChatStates[chatID] else {
|
||||||
return
|
return
|
||||||
@@ -1761,6 +2138,31 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let message = toolCallMessage(for: payload)
|
||||||
|
|
||||||
|
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
|
||||||
|
pending.messages.insert(message, at: assistantIndex)
|
||||||
|
} else {
|
||||||
|
pending.messages.append(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingChatStates[chatID] = pending
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||||
|
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = toolCallMessage(for: payload)
|
||||||
|
if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
|
||||||
|
quickQuestionMessages.insert(message, at: assistantIndex)
|
||||||
|
} else {
|
||||||
|
quickQuestionMessages.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
||||||
let metadata: JSONValue = .object([
|
let metadata: JSONValue = .object([
|
||||||
"kind": .string("tool_call"),
|
"kind": .string("tool_call"),
|
||||||
"toolCallId": .string(payload.toolCallId),
|
"toolCallId": .string(payload.toolCallId),
|
||||||
@@ -1779,7 +2181,7 @@ final class SybilViewModel {
|
|||||||
? "Ran tool '\(payload.name)'."
|
? "Ran tool '\(payload.name)'."
|
||||||
: payload.summary
|
: payload.summary
|
||||||
|
|
||||||
let message = Message(
|
return Message(
|
||||||
id: "temp-tool-\(payload.toolCallId)",
|
id: "temp-tool-\(payload.toolCallId)",
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
role: .tool,
|
role: .tool,
|
||||||
@@ -1787,14 +2189,6 @@ final class SybilViewModel {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
metadata: metadata
|
metadata: metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
|
|
||||||
pending.messages.insert(message, at: assistantIndex)
|
|
||||||
} else {
|
|
||||||
pending.messages.append(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingChatStates[chatID] = pending
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentChatID: String? {
|
private var currentChatID: String? {
|
||||||
|
|||||||
@@ -232,13 +232,7 @@ struct SybilWorkspaceView: View {
|
|||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
|
|
||||||
Text(viewModel.selectedTitle)
|
customWorkspaceNavigationTitle
|
||||||
.font(.sybil(size: 16, weight: .semibold))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.78)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
|
|
||||||
workspaceNavigationTrailingControl
|
workspaceNavigationTrailingControl
|
||||||
}
|
}
|
||||||
@@ -251,6 +245,32 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var selectedProviderModelSubtitle: String {
|
||||||
|
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !selectedModel.isEmpty else {
|
||||||
|
return viewModel.provider.displayName
|
||||||
|
}
|
||||||
|
return "\(viewModel.provider.displayName) • \(selectedModel)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customWorkspaceNavigationTitle: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(viewModel.selectedTitle)
|
||||||
|
.font(.sybil(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.78)
|
||||||
|
|
||||||
|
Text(selectedProviderModelSubtitle)
|
||||||
|
.font(.sybil(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var workspaceNavigationLeadingControl: some View {
|
private var workspaceNavigationLeadingControl: some View {
|
||||||
switch navigationLeadingControl {
|
switch navigationLeadingControl {
|
||||||
@@ -495,7 +515,7 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
ForEach(viewModel.providerOptions, id: \.self) { candidate in
|
||||||
Menu(candidate.displayName) {
|
Menu(candidate.displayName) {
|
||||||
let models = viewModel.modelOptions(for: candidate)
|
let models = viewModel.modelOptions(for: candidate)
|
||||||
if models.isEmpty {
|
if models.isEmpty {
|
||||||
@@ -703,9 +723,7 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
if !viewModel.isSearchMode {
|
composerFocused = false
|
||||||
composerFocused = false
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -4,26 +4,42 @@ import Testing
|
|||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
|
var listWorkspaceItems = 0
|
||||||
var listChats = 0
|
var listChats = 0
|
||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
|
var runCompletionStream = 0
|
||||||
var attachCompletionStream = 0
|
var attachCompletionStream = 0
|
||||||
var attachSearchStream = 0
|
var attachSearchStream = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatCreateCallSnapshot: Sendable {
|
||||||
|
var title: String?
|
||||||
|
var provider: Provider?
|
||||||
|
var model: String?
|
||||||
|
var messages: [CompletionRequestMessage]?
|
||||||
|
}
|
||||||
|
|
||||||
private struct UnexpectedClientCall: Error {}
|
private struct UnexpectedClientCall: Error {}
|
||||||
|
|
||||||
private actor MockSybilClient: SybilAPIClienting {
|
private actor MockSybilClient: SybilAPIClienting {
|
||||||
private let chatsResponse: [ChatSummary]
|
private let chatsResponse: [ChatSummary]
|
||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
|
private let workspaceItemsResponse: [WorkspaceItem]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
|
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||||
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
|
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||||
|
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
@@ -41,25 +57,49 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
|
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
|
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
||||||
|
(chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentCreateChatCall() -> ChatCreateCallSnapshot? {
|
||||||
|
lastCreateChatCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentCompletionStreamBody() -> CompletionStreamRequest? {
|
||||||
|
lastCompletionStreamBody
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
||||||
|
completionStreamEvents = events
|
||||||
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
completionStreamNetworkErrorMessage = message
|
completionStreamNetworkErrorMessage = message
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||||
|
listChatsDelayNanoseconds = chats
|
||||||
|
listSearchesDelayNanoseconds = searches
|
||||||
|
}
|
||||||
|
|
||||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||||
getChatDelayNanoseconds = delayNanoseconds
|
getChatDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
@@ -95,12 +135,36 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listWorkspaceItems() async throws -> [WorkspaceItem] {
|
||||||
|
snapshot.listWorkspaceItems += 1
|
||||||
|
let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
|
||||||
|
if delay > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: delay)
|
||||||
|
}
|
||||||
|
return workspaceItemsResponse
|
||||||
|
}
|
||||||
|
|
||||||
func listChats() async throws -> [ChatSummary] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
snapshot.listChats += 1
|
snapshot.listChats += 1
|
||||||
|
if listChatsDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||||
|
}
|
||||||
return chatsResponse
|
return chatsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String?) async throws -> ChatSummary {
|
func createChat(
|
||||||
|
title: String?,
|
||||||
|
provider: Provider?,
|
||||||
|
model: String?,
|
||||||
|
messages: [CompletionRequestMessage]?
|
||||||
|
) async throws -> ChatSummary {
|
||||||
|
snapshot.createChat += 1
|
||||||
|
lastCreateChatCall = ChatCreateCallSnapshot(
|
||||||
|
title: title,
|
||||||
|
provider: provider,
|
||||||
|
model: model,
|
||||||
|
messages: messages
|
||||||
|
)
|
||||||
if let createChatResponse {
|
if let createChatResponse {
|
||||||
return createChatResponse
|
return createChatResponse
|
||||||
}
|
}
|
||||||
@@ -128,6 +192,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func listSearches() async throws -> [SearchSummary] {
|
func listSearches() async throws -> [SearchSummary] {
|
||||||
snapshot.listSearches += 1
|
snapshot.listSearches += 1
|
||||||
|
if listSearchesDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||||
|
}
|
||||||
return searchesResponse
|
return searchesResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +234,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
|
snapshot.runCompletionStream += 1
|
||||||
|
lastCompletionStreamBody = body
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
}
|
}
|
||||||
if let completionStreamNetworkErrorMessage {
|
if let completionStreamNetworkErrorMessage {
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||||
}
|
}
|
||||||
|
if let completionStreamEvents {
|
||||||
|
for event in completionStreamEvents {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +406,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
#expect(snapshot.listChats == 1)
|
#expect(snapshot.listWorkspaceItems == 1)
|
||||||
#expect(snapshot.listSearches == 1)
|
#expect(snapshot.listChats == 0)
|
||||||
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 0)
|
#expect(snapshot.getChat == 0)
|
||||||
#expect(snapshot.getSearch == 0)
|
#expect(snapshot.getSearch == 0)
|
||||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
#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
|
@MainActor
|
||||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
@@ -351,6 +454,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listWorkspaceItems == 0)
|
||||||
#expect(snapshot.listChats == 0)
|
#expect(snapshot.listChats == 0)
|
||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
@@ -370,6 +474,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listWorkspaceItems == 0)
|
||||||
#expect(snapshot.listChats == 0)
|
#expect(snapshot.listChats == 0)
|
||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getSearch == 1)
|
#expect(snapshot.getSearch == 1)
|
||||||
@@ -470,6 +575,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
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
|
@MainActor
|
||||||
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
Backend API for:
|
||||||
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok)
|
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
|
||||||
- Personal chat database (chats/messages + LLM call log)
|
- Personal chat database (chats/messages + LLM call log)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `OPENAI_API_KEY`
|
- `OPENAI_API_KEY`
|
||||||
- `ANTHROPIC_API_KEY`
|
- `ANTHROPIC_API_KEY`
|
||||||
- `XAI_API_KEY`
|
- `XAI_API_KEY`
|
||||||
|
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
|
||||||
|
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
|
||||||
|
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
|
||||||
- `EXA_API_KEY`
|
- `EXA_API_KEY`
|
||||||
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
|
||||||
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MessageRole {
|
enum MessageRole {
|
||||||
@@ -50,6 +51,9 @@ model Chat {
|
|||||||
lastUsedProvider Provider?
|
lastUsedProvider Provider?
|
||||||
lastUsedModel String?
|
lastUsedModel String?
|
||||||
|
|
||||||
|
additionalSystemPrompt String?
|
||||||
|
enabledTools Json?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
|
|||||||
z.string().trim().url().optional()
|
z.string().trim().url().optional()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DEFAULT_HERMES_AGENT_API_BASE_URL = "http://127.0.0.1:8642/v1";
|
||||||
|
|
||||||
|
const HermesAgentApiBaseUrlSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
|
||||||
|
z.string().trim().url().default(DEFAULT_HERMES_AGENT_API_BASE_URL)
|
||||||
|
);
|
||||||
|
|
||||||
const ChatWebSearchEngineSchema = z.preprocess(
|
const ChatWebSearchEngineSchema = z.preprocess(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (typeof value !== "string") return value;
|
if (typeof value !== "string") return value;
|
||||||
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
XAI_API_KEY: z.string().optional(),
|
XAI_API_KEY: z.string().optional(),
|
||||||
|
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||||
|
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||||
|
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||||
EXA_API_KEY: z.string().optional(),
|
EXA_API_KEY: z.string().optional(),
|
||||||
|
|
||||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
|
|||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { ensureDatabaseReady } from "./db-init.js";
|
import { ensureDatabaseReady } from "./db-init.js";
|
||||||
import { warmModelCatalog } from "./llm/model-catalog.js";
|
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||||
import { registerRoutes } from "./routes.js";
|
import { registerRoutes } from "./routes.js";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -21,6 +21,7 @@ const app = Fastify({
|
|||||||
|
|
||||||
await ensureDatabaseReady(app.log);
|
await ensureDatabaseReady(app.log);
|
||||||
await warmModelCatalog(app.log);
|
await warmModelCatalog(app.log);
|
||||||
|
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: true,
|
origin: true,
|
||||||
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
stopModelCatalogRefreshLoop();
|
||||||
|
});
|
||||||
|
|
||||||
await registerRoutes(app);
|
await registerRoutes(app);
|
||||||
|
|
||||||
await app.listen({ port: env.PORT, host: env.HOST });
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { z } from "zod";
|
|||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { exaClient } from "../search/exa.js";
|
import { exaClient } from "../search/exa.js";
|
||||||
import { searchSearxng } from "../search/searxng.js";
|
import { searchSearxng } from "../search/searxng.js";
|
||||||
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
import {
|
||||||
|
buildOpenAIConversationMessage,
|
||||||
|
buildOpenAIResponsesInputMessage,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
} from "./message-content.js";
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||||
@@ -188,7 +192,43 @@ const CHAT_TOOLS: any[] = [
|
|||||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
function getToolName(tool: any) {
|
||||||
|
return typeof tool?.function?.name === "string" ? tool.function.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableChatTools() {
|
||||||
|
return CHAT_TOOLS.map((tool) => {
|
||||||
|
const name = getToolName(tool);
|
||||||
|
if (!name) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
|
||||||
|
};
|
||||||
|
}).filter((tool): tool is { name: string; description: string } => tool !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEnabledChatTools(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
|
||||||
|
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
|
||||||
|
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||||
|
available.has(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
|
const enabled = getEnabledToolSet(params);
|
||||||
|
return CHAT_TOOLS.filter((tool) => {
|
||||||
|
const name = getToolName(tool);
|
||||||
|
return name ? enabled.has(name) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResponsesChatTools(tools: any[]) {
|
||||||
|
return tools.map((tool) => {
|
||||||
if (tool?.type !== "function") return tool;
|
if (tool?.type !== "function") return tool;
|
||||||
return {
|
return {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -197,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
|||||||
parameters: tool.function.parameters,
|
parameters: tool.function.parameters,
|
||||||
strict: false,
|
strict: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
|
|||||||
client: OpenAI;
|
client: OpenAI;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||||
@@ -379,16 +422,38 @@ function extractHtmlTitle(html: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
const enabled = getEnabledToolSet(params);
|
||||||
|
return (
|
||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
|
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
|
||||||
|
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
|
||||||
|
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
|
||||||
|
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
|
||||||
|
(enabled.has("codex_exec")
|
||||||
|
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
|
||||||
|
: "") +
|
||||||
|
(enabled.has("shell_exec")
|
||||||
|
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
|
||||||
|
: "") +
|
||||||
|
"Do not fabricate tool outputs; reason only from provided tool results."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
|
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||||
|
|
||||||
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||||
|
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||||
|
|
||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
@@ -853,6 +918,20 @@ function extractResponsesText(response: any, fallback = "") {
|
|||||||
return parts.join("") || fallback;
|
return parts.join("") || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatCompletionContent(message: any) {
|
||||||
|
if (typeof message?.content === "string") return message.content;
|
||||||
|
if (!Array.isArray(message?.content)) return "";
|
||||||
|
|
||||||
|
return message.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (typeof part?.text === "string") return part.text;
|
||||||
|
if (typeof part?.content === "string") return part.content;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
function getUnstreamedText(finalText: string, streamedText: string) {
|
||||||
if (!finalText) return "";
|
if (!finalText) return "";
|
||||||
if (!streamedText) return finalText;
|
if (!streamedText) return finalText;
|
||||||
@@ -939,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -953,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: RESPONSES_CHAT_TOOLS,
|
tools: toResponsesChatTools(enabledTools),
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1008,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1022,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: CHAT_TOOLS,
|
tools: enabledTools,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
} as any);
|
} as any);
|
||||||
rawResponses.push(completion);
|
rawResponses.push(completion);
|
||||||
@@ -1093,10 +1174,31 @@ 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, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractChatCompletionContent(message),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response: completion, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function* runToolAwareOpenAIChatStream(
|
export async function* runToolAwareOpenAIChatStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1110,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: RESPONSES_CHAT_TOOLS,
|
tools: toResponsesChatTools(enabledTools),
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1222,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
export async function* runToolAwareChatCompletionsStream(
|
export async function* runToolAwareChatCompletionsStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1236,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: CHAT_TOOLS,
|
tools: enabledTools,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
@@ -1354,3 +1457,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, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
text += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_USER_LOCATION = "San Francisco, CA";
|
||||||
|
|
||||||
|
function currentDateString(now = new Date()) {
|
||||||
|
return now.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserLocation(userLocation?: string) {
|
||||||
|
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
|
||||||
|
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeAttribute(value: string) {
|
function escapeAttribute(value: string) {
|
||||||
return value.replace(/"/g, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content: buildSystemPromptAugmentation(userLocation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
||||||
|
|
||||||
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
|
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { env } from "../env.js";
|
||||||
|
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
export type ProviderModelSnapshot = {
|
export type ProviderModelSnapshot = {
|
||||||
@@ -8,10 +9,11 @@ export type ProviderModelSnapshot = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
|
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||||
|
|
||||||
const providers: Provider[] = ["openai", "anthropic", "xai"];
|
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const modelCatalog: ModelCatalogSnapshot = {
|
const modelCatalog: ModelCatalogSnapshot = {
|
||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
@@ -19,6 +21,12 @@ const modelCatalog: ModelCatalogSnapshot = {
|
|||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let catalogRefreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function getCatalogProviders(): Provider[] {
|
||||||
|
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqSorted(models: string[]) {
|
function uniqSorted(models: string[]) {
|
||||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
@@ -59,8 +67,15 @@ async function fetchProviderModels(provider: Provider) {
|
|||||||
return uniqSorted(page.data.map((model) => model.id));
|
return uniqSorted(page.data.map((model) => model.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await xaiClient().models.list();
|
if (provider === "xai") {
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
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) {
|
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||||
@@ -74,35 +89,53 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
|||||||
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err?.message ?? String(err);
|
const message = err?.message ?? String(err);
|
||||||
|
const previous = modelCatalog[provider];
|
||||||
|
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||||
modelCatalog[provider] = {
|
modelCatalog[provider] = {
|
||||||
models: [],
|
models: previous?.models.length ? previous.models : fallbackModels,
|
||||||
loadedAt: new Date().toISOString(),
|
loadedAt: previous?.loadedAt ?? null,
|
||||||
error: message,
|
error: message,
|
||||||
};
|
};
|
||||||
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
|
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
||||||
|
if (catalogRefreshPromise) return catalogRefreshPromise;
|
||||||
|
|
||||||
|
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||||
|
.then(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
catalogRefreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return catalogRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
|
||||||
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger)));
|
await refreshModelCatalog(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void refreshModelCatalog(logger);
|
||||||
|
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||||
return {
|
const snapshot: ModelCatalogSnapshot = {};
|
||||||
openai: {
|
for (const provider of getCatalogProviders()) {
|
||||||
models: [...modelCatalog.openai.models],
|
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||||
loadedAt: modelCatalog.openai.loadedAt,
|
snapshot[provider] = {
|
||||||
error: modelCatalog.openai.error,
|
models: [...entry.models],
|
||||||
},
|
loadedAt: entry.loadedAt,
|
||||||
anthropic: {
|
error: entry.error,
|
||||||
models: [...modelCatalog.anthropic.models],
|
};
|
||||||
loadedAt: modelCatalog.anthropic.loadedAt,
|
}
|
||||||
error: modelCatalog.anthropic.error,
|
return snapshot;
|
||||||
},
|
|
||||||
xai: {
|
|
||||||
models: [...modelCatalog.xai.models],
|
|
||||||
loadedAt: modelCatalog.xai.loadedAt,
|
|
||||||
error: modelCatalog.xai.error,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
|
|
||||||
function asProviderEnum(p: Provider) {
|
function asProviderEnum(p: Provider) {
|
||||||
// Prisma enum values match these strings.
|
return toPrismaProvider(p);
|
||||||
return p;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
||||||
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
|
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||||
|
|
||||||
if (req.provider === "openai") {
|
if (req.provider === "openai" && enabledTools.length > 0) {
|
||||||
const client = openaiClient();
|
const client = openaiClient();
|
||||||
const r = await runToolAwareOpenAIChat({
|
const r = await runToolAwareOpenAIChat({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "xai") {
|
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
||||||
const client = xaiClient();
|
const client = xaiClient();
|
||||||
const r = await runToolAwareChatCompletions({
|
const r = await runToolAwareChatCompletions({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -84,10 +89,28 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
|
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
|
const r = await runPlainChatCompletions({
|
||||||
|
client,
|
||||||
|
model: req.model,
|
||||||
|
messages: req.messages,
|
||||||
|
userLocation: req.userLocation,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
|
chatId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
raw = r.raw;
|
||||||
|
outText = r.text;
|
||||||
|
usage = r.usage;
|
||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages);
|
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const r = await client.messages.create({
|
const r = await client.messages.create({
|
||||||
|
|||||||
31
server/src/llm/provider-ids.ts
Normal file
31
server/src/llm/provider-ids.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
|
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||||
|
|
||||||
|
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||||
|
return provider === "hermes-agent" ? "hermes_agent" : provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||||
|
if (provider === null || provider === undefined) return null;
|
||||||
|
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
|
||||||
|
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||||
|
const next: Record<string, any> = { ...value };
|
||||||
|
if ("initiatedProvider" in next) {
|
||||||
|
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
|
||||||
|
}
|
||||||
|
if ("lastUsedProvider" in next) {
|
||||||
|
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
|
||||||
|
}
|
||||||
|
if ("provider" in next) {
|
||||||
|
next.provider = fromPrismaProvider(next.provider);
|
||||||
|
}
|
||||||
|
if (Array.isArray(next.calls)) {
|
||||||
|
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
@@ -13,6 +13,18 @@ export function xaiClient() {
|
|||||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isHermesAgentConfigured() {
|
||||||
|
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hermesAgentClient() {
|
||||||
|
if (!env.HERMES_AGENT_API_KEY) throw new Error("HERMES_AGENT_API_KEY not set");
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: env.HERMES_AGENT_API_KEY,
|
||||||
|
baseURL: env.HERMES_AGENT_API_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function anthropicClient() {
|
export function anthropicClient() {
|
||||||
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
||||||
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import {
|
import {
|
||||||
buildToolLogMessageData,
|
buildToolLogMessageData,
|
||||||
|
normalizeEnabledChatTools,
|
||||||
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
type ToolExecutionEvent,
|
type ToolExecutionEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
type StreamUsage = {
|
type StreamUsage = {
|
||||||
@@ -38,7 +41,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
? await prisma.llmCall.create({
|
? await prisma.llmCall.create({
|
||||||
data: {
|
data: {
|
||||||
chatId,
|
chatId,
|
||||||
provider: req.provider as any,
|
provider: toPrismaProvider(req.provider) as any,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
request: req as any,
|
request: req as any,
|
||||||
},
|
},
|
||||||
@@ -51,14 +54,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
prisma.chat.update({
|
prisma.chat.update({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: {
|
data: {
|
||||||
lastUsedProvider: req.provider as any,
|
lastUsedProvider: toPrismaProvider(req.provider) as any,
|
||||||
lastUsedModel: req.model,
|
lastUsedModel: req.model,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.chat.updateMany({
|
prisma.chat.updateMany({
|
||||||
where: { id: chatId, initiatedProvider: null },
|
where: { id: chatId, initiatedProvider: null },
|
||||||
data: {
|
data: {
|
||||||
initiatedProvider: req.provider as any,
|
initiatedProvider: toPrismaProvider(req.provider) as any,
|
||||||
initiatedModel: req.model,
|
initiatedModel: req.model,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -72,14 +75,17 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
|
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||||
const streamEvents =
|
const streamEvents =
|
||||||
req.provider === "openai"
|
req.provider === "openai" && enabledTools.length > 0
|
||||||
? runToolAwareOpenAIChatStream({
|
? runToolAwareOpenAIChatStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -88,10 +94,26 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId: chatId ?? undefined,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||||
|
? runPlainChatCompletionsStream({
|
||||||
|
client,
|
||||||
|
model: req.model,
|
||||||
|
messages: req.messages,
|
||||||
|
userLocation: req.userLocation,
|
||||||
|
temperature: req.temperature,
|
||||||
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
|
chatId: chatId ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
: runToolAwareChatCompletionsStream({
|
: runToolAwareChatCompletionsStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -131,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages);
|
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const stream = await client.messages.create({
|
const stream = await client.messages.create({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
|
||||||
|
|
||||||
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
export type ChatImageAttachment = {
|
export type ChatImageAttachment = {
|
||||||
kind: "image";
|
kind: "image";
|
||||||
@@ -34,6 +36,9 @@ export type MultiplexRequest = {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import { env } from "./env.js";
|
|||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
|
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -44,6 +50,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
|||||||
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHeaderString(req: FastifyRequest, name: string) {
|
||||||
|
const value = req.headers[name.toLowerCase()];
|
||||||
|
if (Array.isArray(value)) return value.find((item) => item.trim());
|
||||||
|
return typeof value === "string" && value.trim() ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHeaderPart(value: string | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRequestUserLocation(req: FastifyRequest) {
|
||||||
|
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
|
||||||
|
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
|
||||||
|
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
|
||||||
|
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
|
||||||
|
if (vercelLocation) return vercelLocation;
|
||||||
|
|
||||||
|
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
|
||||||
|
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
|
||||||
|
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
|
||||||
|
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
|
||||||
|
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||||
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
@@ -125,9 +168,12 @@ const CompletionStreamBody = z
|
|||||||
.object({
|
.object({
|
||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
persist: z.boolean().optional(),
|
persist: z.boolean().optional(),
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
@@ -152,6 +198,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
|
||||||
|
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
|
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
additionalSystemPrompt,
|
||||||
|
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
|
||||||
|
body: T
|
||||||
|
) {
|
||||||
|
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
|
||||||
|
return prependAdditionalSystemPrompt(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await prisma.chat.findUnique({
|
||||||
|
where: { id: body.chatId },
|
||||||
|
select: { additionalSystemPrompt: true, enabledTools: true },
|
||||||
|
});
|
||||||
|
if (!chat) return prependAdditionalSystemPrompt(body);
|
||||||
|
|
||||||
|
return prependAdditionalSystemPrompt({
|
||||||
|
...body,
|
||||||
|
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
|
||||||
|
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SearchRunBody = z.object({
|
const SearchRunBody = z.object({
|
||||||
query: z.string().trim().min(1).optional(),
|
query: z.string().trim().min(1).optional(),
|
||||||
title: z.string().trim().min(1).optional(),
|
title: z.string().trim().min(1).optional(),
|
||||||
@@ -323,6 +404,41 @@ function getErrorMessage(err: unknown) {
|
|||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWorkspaceItems() {
|
||||||
|
const [chats, searches] = await Promise.all([
|
||||||
|
prisma.chat.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.search.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
|
||||||
|
...searches.map((search) => ({ type: "search" as const, ...search })),
|
||||||
|
].sort(compareUpdatedAtDesc);
|
||||||
|
}
|
||||||
|
|
||||||
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||||
reply.raw.write(`event: ${event.event}\n`);
|
reply.raw.write(`event: ${event.event}\n`);
|
||||||
@@ -567,6 +683,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { providers: getModelCatalogSnapshot() };
|
return { providers: getModelCatalogSnapshot() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/v1/chat-tools", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
return { tools: getAvailableChatTools() };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/v1/active-runs", async (req) => {
|
app.get("/v1/active-runs", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
return {
|
return {
|
||||||
@@ -575,6 +696,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/v1/workspace-items", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
return { items: await listWorkspaceItems() };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
@@ -589,9 +715,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { chats };
|
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
@@ -599,8 +727,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const Body = z
|
const Body = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]).optional(),
|
provider: ProviderSchema.optional(),
|
||||||
model: z.string().trim().min(1).optional(),
|
model: z.string().trim().min(1).optional(),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
messages: z.array(CompletionMessageSchema).optional(),
|
messages: z.array(CompletionMessageSchema).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
@@ -625,10 +755,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.create({
|
const chat = await prisma.chat.create({
|
||||||
data: {
|
data: {
|
||||||
title: body.title,
|
title: body.title,
|
||||||
initiatedProvider: body.provider as any,
|
initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
initiatedModel: body.model,
|
initiatedModel: body.model,
|
||||||
lastUsedProvider: body.provider as any,
|
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
lastUsedModel: body.model,
|
lastUsedModel: body.model,
|
||||||
|
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
||||||
|
enabledTools: body.enabledTools as any,
|
||||||
messages: body.messages?.length
|
messages: body.messages?.length
|
||||||
? {
|
? {
|
||||||
create: body.messages.map((message) => ({
|
create: body.messages.map((message) => ({
|
||||||
@@ -649,21 +781,32 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
const Body = z.object({ title: z.string().trim().min(1) });
|
const Body = z.object({
|
||||||
|
title: z.string().trim().min(1).optional(),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
});
|
||||||
const { chatId } = Params.parse(req.params);
|
const { chatId } = Params.parse(req.params);
|
||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.title !== undefined) data.title = body.title;
|
||||||
|
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
|
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||||
|
|
||||||
const updated = await prisma.chat.updateMany({
|
const updated = await prisma.chat.updateMany({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: { title: body.title },
|
data: data as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||||
@@ -679,10 +822,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/title/suggest", async (req) => {
|
app.post("/v1/chats/title/suggest", async (req) => {
|
||||||
@@ -704,10 +849,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: existing };
|
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
@@ -725,10 +872,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/v1/chats/:chatId", async (req) => {
|
app.delete("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -845,10 +994,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: true,
|
initiatedModel: true,
|
||||||
lastUsedProvider: true,
|
lastUsedProvider: true,
|
||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
@@ -994,7 +1145,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
@@ -1041,16 +1192,19 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1063,7 +1217,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runMultiplex(body);
|
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId: body.chatId ?? null,
|
chatId: body.chatId ?? null,
|
||||||
@@ -1077,7 +1231,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1094,14 +1248,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (activeChatStreams.has(body.chatId)) {
|
if (activeChatStreams.has(body.chatId)) {
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
const stream = startActiveChatStream(body.chatId, body);
|
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||||
return streamActiveRun(req, reply, stream);
|
return streamActiveRun(req, reply, stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import {
|
||||||
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
@@ -105,3 +106,37 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
|||||||
);
|
);
|
||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Hi" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
runPlainChatCompletionsStream({
|
||||||
|
client: client as any,
|
||||||
|
model: "hermes-agent",
|
||||||
|
messages: [{ role: "user", content: "Say hi" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(requestBody.model, "hermes-agent");
|
||||||
|
assert.equal(requestBody.stream, true);
|
||||||
|
assert.equal("tools" in requestBody, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["delta", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
|
});
|
||||||
|
|||||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
||||||
|
|
||||||
|
test("system prompt augmentation includes date and default location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system prompt augmentation uses provided user location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
||||||
|
const prompt = getAnthropicSystemPrompt(
|
||||||
|
[{ role: "system", content: "Use concise answers." }],
|
||||||
|
"Los Angeles, CA"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||||
|
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||||
|
assert.match(prompt, /Use concise answers\./);
|
||||||
|
});
|
||||||
12
server/tests/provider-ids.test.ts
Normal file
12
server/tests/provider-ids.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
|
||||||
|
|
||||||
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
|
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
|
||||||
|
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
|
||||||
|
initiatedProvider: "hermes-agent",
|
||||||
|
lastUsedProvider: "xai",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
|
|||||||
|
|
||||||
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
||||||
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
||||||
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
|
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` | `hermes-agent` (default: `openai`)
|
||||||
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
||||||
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
SearchStreamHandlers,
|
SearchStreamHandlers,
|
||||||
SearchSummary,
|
SearchSummary,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
WorkspaceItem,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
type RequestOptions = {
|
type RequestOptions = {
|
||||||
@@ -41,6 +42,11 @@ export class SybilApiClient {
|
|||||||
return data.chats;
|
return data.chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listWorkspaceItems() {
|
||||||
|
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
async createChat(title?: string) {
|
async createChat(title?: string) {
|
||||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -94,6 +100,7 @@ export class SybilApiClient {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
userLocation?: string;
|
||||||
},
|
},
|
||||||
handlers: CompletionStreamHandlers,
|
handlers: CompletionStreamHandlers,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SearchDetail,
|
SearchDetail,
|
||||||
SearchSummary,
|
SearchSummary,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
|
WorkspaceItem,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||||
@@ -39,11 +40,13 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
resultPreview?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
|
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||||
@@ -74,6 +77,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +94,38 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
|||||||
return "New search";
|
return "New search";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||||
const items: SidebarItem[] = [
|
return { type: "chat", ...chat };
|
||||||
...chats.map((chat) => ({
|
}
|
||||||
|
|
||||||
|
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
|
||||||
|
return { type: "search", ...search };
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitWorkspaceItems(items: WorkspaceItem[]) {
|
||||||
|
const chats: ChatSummary[] = [];
|
||||||
|
const searches: SearchSummary[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === "chat") {
|
||||||
|
const { type: _type, ...chat } = item;
|
||||||
|
chats.push(chat);
|
||||||
|
} else {
|
||||||
|
const { type: _type, ...search } = item;
|
||||||
|
searches.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { chats, searches };
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
||||||
|
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
if (item.type === "chat") {
|
||||||
|
const chat = item;
|
||||||
|
return {
|
||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
@@ -102,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
})),
|
};
|
||||||
...searches.map((search) => ({
|
}
|
||||||
|
|
||||||
|
const search = item;
|
||||||
|
return {
|
||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
id: search.id,
|
id: search.id,
|
||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
@@ -113,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
lastUsedModel: null,
|
lastUsedModel: null,
|
||||||
})),
|
};
|
||||||
];
|
});
|
||||||
|
|
||||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
@@ -159,6 +193,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
|||||||
return PROVIDER_FALLBACK_MODELS[provider];
|
return PROVIDER_FALLBACK_MODELS[provider];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||||
|
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
||||||
if (fallback && options.includes(fallback)) return fallback;
|
if (fallback && options.includes(fallback)) return fallback;
|
||||||
if (preferred && options.includes(preferred)) return preferred;
|
if (preferred && options.includes(preferred)) return preferred;
|
||||||
@@ -188,6 +226,7 @@ async function main() {
|
|||||||
let authMode: "open" | "token" | null = null;
|
let authMode: "open" | "token" | null = null;
|
||||||
let chats: ChatSummary[] = [];
|
let chats: ChatSummary[] = [];
|
||||||
let searches: SearchSummary[] = [];
|
let searches: SearchSummary[] = [];
|
||||||
|
let workspaceItems: WorkspaceItem[] = [];
|
||||||
let selectedItem: SidebarSelection | null = null;
|
let selectedItem: SidebarSelection | null = null;
|
||||||
let selectedChat: ChatDetail | null = null;
|
let selectedChat: ChatDetail | null = null;
|
||||||
let selectedSearch: SearchDetail | null = null;
|
let selectedSearch: SearchDetail | null = null;
|
||||||
@@ -202,6 +241,7 @@ async function main() {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
@@ -369,7 +409,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSidebarItems() {
|
function getSidebarItems() {
|
||||||
return buildSidebarItems(chats, searches);
|
return buildSidebarItems(workspaceItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedChatSummary() {
|
function getSelectedChatSummary() {
|
||||||
@@ -693,6 +733,7 @@ async function main() {
|
|||||||
function resetWorkspaceState() {
|
function resetWorkspaceState() {
|
||||||
chats = [];
|
chats = [];
|
||||||
searches = [];
|
searches = [];
|
||||||
|
workspaceItems = [];
|
||||||
selectedItem = null;
|
selectedItem = null;
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
selectedSearch = null;
|
selectedSearch = null;
|
||||||
@@ -759,11 +800,13 @@ async function main() {
|
|||||||
updateUI();
|
updateUI();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
|
const nextWorkspaceItems = await api.listWorkspaceItems();
|
||||||
|
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||||
|
workspaceItems = nextWorkspaceItems;
|
||||||
chats = nextChats;
|
chats = nextChats;
|
||||||
searches = nextSearches;
|
searches = nextSearches;
|
||||||
|
|
||||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||||
selectedItem = options.preferredSelection;
|
selectedItem = options.preferredSelection;
|
||||||
draftKind = null;
|
draftKind = null;
|
||||||
@@ -868,6 +911,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||||
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
if (selectedChat?.id === updated.id) {
|
if (selectedChat?.id === updated.id) {
|
||||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||||
}
|
}
|
||||||
@@ -912,6 +956,7 @@ async function main() {
|
|||||||
chatId = chat.id;
|
chatId = chat.id;
|
||||||
draftKind = null;
|
draftKind = null;
|
||||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||||
|
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||||
selectedItem = { kind: "chat", id: chat.id };
|
selectedItem = { kind: "chat", id: chat.id };
|
||||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||||
selectedChat = {
|
selectedChat = {
|
||||||
@@ -1077,6 +1122,7 @@ async function main() {
|
|||||||
draftKind = null;
|
draftKind = null;
|
||||||
selectedItem = { kind: "search", id: searchId };
|
selectedItem = { kind: "search", id: searchId };
|
||||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||||
|
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
forceScrollToBottom = true;
|
forceScrollToBottom = true;
|
||||||
updateUI();
|
updateUI();
|
||||||
@@ -1257,8 +1303,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cycleProvider() {
|
function cycleProvider() {
|
||||||
const currentIndex = PROVIDERS.indexOf(provider);
|
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||||
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
|
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||||
|
const currentIndex = Math.max(0, cycleProviders.indexOf(provider));
|
||||||
|
const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai";
|
||||||
provider = nextProvider;
|
provider = nextProvider;
|
||||||
syncModelForProvider();
|
syncModelForProvider();
|
||||||
updateUI();
|
updateUI();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogResponse = {
|
export type ModelCatalogResponse = {
|
||||||
providers: Record<Provider, ProviderModelInfo>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
@@ -29,6 +29,16 @@ export type SearchSummary = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
|
type: "chat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWorkspaceItem = SearchSummary & {
|
||||||
|
type: "search";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
306
web/src/App.tsx
306
web/src/App.tsx
@@ -1,5 +1,20 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Globe2,
|
||||||
|
LoaderCircle,
|
||||||
|
Menu,
|
||||||
|
MessageSquare,
|
||||||
|
Paperclip,
|
||||||
|
Plus,
|
||||||
|
Rabbit,
|
||||||
|
Search,
|
||||||
|
SendHorizontal,
|
||||||
|
Settings2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-preact";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -18,13 +33,14 @@ import {
|
|||||||
attachSearchStream,
|
attachSearchStream,
|
||||||
getActiveRuns,
|
getActiveRuns,
|
||||||
getChat,
|
getChat,
|
||||||
|
listChatTools,
|
||||||
listModels,
|
listModels,
|
||||||
getSearch,
|
getSearch,
|
||||||
listChats,
|
listWorkspaceItems,
|
||||||
listSearches,
|
|
||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
|
updateChatSettings,
|
||||||
getMessageAttachments,
|
getMessageAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ActiveRunsResponse,
|
type ActiveRunsResponse,
|
||||||
@@ -32,11 +48,13 @@ import {
|
|||||||
type Provider,
|
type Provider,
|
||||||
type ChatDetail,
|
type ChatDetail,
|
||||||
type ChatSummary,
|
type ChatSummary,
|
||||||
|
type ChatToolInfo,
|
||||||
type CompletionRequestMessage,
|
type CompletionRequestMessage,
|
||||||
type Message,
|
type Message,
|
||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
type SearchSummary,
|
type SearchSummary,
|
||||||
type ToolCallEvent,
|
type ToolCallEvent,
|
||||||
|
type WorkspaceItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -95,6 +113,7 @@ const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
|||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||||
@@ -103,6 +122,9 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
|
const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
|
|
||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||||
|
|
||||||
@@ -117,6 +139,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
||||||
chats: {},
|
chats: {},
|
||||||
@@ -193,6 +216,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
|||||||
return PROVIDER_FALLBACK_MODELS[provider];
|
return PROVIDER_FALLBACK_MODELS[provider];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||||
|
return ALL_PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function getReplyScrollBufferHeight() {
|
function getReplyScrollBufferHeight() {
|
||||||
if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN;
|
if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
@@ -308,6 +335,8 @@ function loadStoredModelPreferences() {
|
|||||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||||
|
"hermes-agent":
|
||||||
|
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return EMPTY_MODEL_PREFERENCES;
|
return EMPTY_MODEL_PREFERENCES;
|
||||||
@@ -315,17 +344,19 @@ function loadStoredModelPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredProvider(value: unknown): Provider {
|
function normalizeStoredProvider(value: unknown): Provider {
|
||||||
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai";
|
return value === "anthropic" || value === "xai" || value === "openai" || value === "hermes-agent" ? value : "openai";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
|
||||||
const parsed = value as Partial<Record<Provider, unknown>>;
|
const parsed = value as Partial<Record<Provider, unknown>>;
|
||||||
return {
|
return {
|
||||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||||
};
|
"hermes-agent":
|
||||||
|
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
|
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
|
||||||
@@ -354,9 +385,34 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToolLabel(name: string) {
|
||||||
|
if (name === "web_search") return "Web search";
|
||||||
|
if (name === "fetch_url") return "Fetch URL";
|
||||||
|
if (name === "codex_exec") return "Codex";
|
||||||
|
if (name === "shell_exec") return "Shell";
|
||||||
|
return name
|
||||||
|
.split("_")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
|
||||||
|
return availableTools.map((tool) => tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
||||||
|
const available = new Set(availableTools.map((tool) => tool.name));
|
||||||
|
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
|
||||||
|
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||||
|
available.has(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||||
return {
|
return {
|
||||||
@@ -574,20 +630,48 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
|||||||
return "New search";
|
return "New search";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||||
const items: SidebarItem[] = [
|
return { type: "chat", ...chat };
|
||||||
...chats.map((chat) => ({
|
}
|
||||||
kind: "chat" as const,
|
|
||||||
id: chat.id,
|
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
|
||||||
title: getChatTitle(chat),
|
return { type: "search", ...search };
|
||||||
updatedAt: chat.updatedAt,
|
}
|
||||||
createdAt: chat.createdAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
function splitWorkspaceItems(items: WorkspaceItem[]) {
|
||||||
initiatedModel: chat.initiatedModel,
|
const chats: ChatSummary[] = [];
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
const searches: SearchSummary[] = [];
|
||||||
lastUsedModel: chat.lastUsedModel,
|
for (const item of items) {
|
||||||
})),
|
if (item.type === "chat") {
|
||||||
...searches.map((search) => ({
|
const { type: _type, ...chat } = item;
|
||||||
|
chats.push(chat);
|
||||||
|
} else {
|
||||||
|
const { type: _type, ...search } = item;
|
||||||
|
searches.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { chats, searches };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
if (item.type === "chat") {
|
||||||
|
const chat = item;
|
||||||
|
return {
|
||||||
|
kind: "chat" as const,
|
||||||
|
id: chat.id,
|
||||||
|
title: getChatTitle(chat),
|
||||||
|
updatedAt: chat.updatedAt,
|
||||||
|
createdAt: chat.createdAt,
|
||||||
|
initiatedProvider: chat.initiatedProvider,
|
||||||
|
initiatedModel: chat.initiatedModel,
|
||||||
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = item;
|
||||||
|
return {
|
||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
id: search.id,
|
id: search.id,
|
||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
@@ -597,10 +681,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
lastUsedModel: null,
|
lastUsedModel: null,
|
||||||
})),
|
};
|
||||||
];
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem, moveToFront = true) {
|
||||||
|
const withoutExisting = items.filter((existing) => existing.type !== item.type || existing.id !== item.id);
|
||||||
|
if (moveToFront) {
|
||||||
|
return [item, ...withoutExisting];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = items.findIndex((existing) => existing.type === item.type && existing.id === item.id);
|
||||||
|
if (existingIndex < 0) return [item, ...items];
|
||||||
|
const next = [...items];
|
||||||
|
next[existingIndex] = item;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
|
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
|
||||||
@@ -661,6 +756,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||||
|
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
|
||||||
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
||||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||||
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
||||||
@@ -676,6 +772,7 @@ export default function App() {
|
|||||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||||
const [provider, setProvider] = useState<Provider>("openai");
|
const [provider, setProvider] = useState<Provider>("openai");
|
||||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||||
|
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
||||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||||
const [model, setModel] = useState(() => {
|
const [model, setModel] = useState(() => {
|
||||||
const stored = loadStoredModelPreferences();
|
const stored = loadStoredModelPreferences();
|
||||||
@@ -698,6 +795,9 @@ export default function App() {
|
|||||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
||||||
|
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
||||||
|
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
||||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -715,6 +815,8 @@ export default function App() {
|
|||||||
const wasSendingRef = useRef(false);
|
const wasSendingRef = useRef(false);
|
||||||
const pendingReplyScrollRef = useRef(false);
|
const pendingReplyScrollRef = useRef(false);
|
||||||
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
||||||
|
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
|
||||||
|
const transcriptViewKeyRef = useRef<string | null>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||||
@@ -739,6 +841,7 @@ export default function App() {
|
|||||||
const settleTranscriptTailSpacer = () => {
|
const settleTranscriptTailSpacer = () => {
|
||||||
const container = transcriptContainerRef.current;
|
const container = transcriptContainerRef.current;
|
||||||
const currentSpacerHeight = transcriptTailSpacerHeightRef.current;
|
const currentSpacerHeight = transcriptTailSpacerHeightRef.current;
|
||||||
|
if (currentSpacerHeight <= TRANSCRIPT_BOTTOM_GAP) return;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
return;
|
return;
|
||||||
@@ -746,7 +849,22 @@ export default function App() {
|
|||||||
|
|
||||||
const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight;
|
const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight;
|
||||||
const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer;
|
const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer;
|
||||||
setTranscriptTailSpacer(requiredSpacerHeight);
|
setTranscriptTailSpacer(Math.min(currentSpacerHeight, requiredSpacerHeight));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestSettleTranscriptTailSpacer = () => {
|
||||||
|
if (transcriptTailSpacerHeightRef.current <= TRANSCRIPT_BOTTOM_GAP) return;
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
settleTranscriptTailSpacer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transcriptTailSpacerSettleFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current);
|
||||||
|
}
|
||||||
|
transcriptTailSpacerSettleFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
transcriptTailSpacerSettleFrameRef.current = null;
|
||||||
|
settleTranscriptTailSpacer();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusComposer = () => {
|
const focusComposer = () => {
|
||||||
@@ -769,7 +887,7 @@ export default function App() {
|
|||||||
pendingAttachmentsRef.current = pendingAttachments;
|
pendingAttachmentsRef.current = pendingAttachments;
|
||||||
}, [pendingAttachments]);
|
}, [pendingAttachments]);
|
||||||
|
|
||||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||||
const filteredSidebarItems = useMemo(() => {
|
const filteredSidebarItems = useMemo(() => {
|
||||||
const query = sidebarQuery.trim().toLowerCase();
|
const query = sidebarQuery.trim().toLowerCase();
|
||||||
if (!query) return sidebarItems;
|
if (!query) return sidebarItems;
|
||||||
@@ -785,6 +903,7 @@ export default function App() {
|
|||||||
const resetWorkspaceState = () => {
|
const resetWorkspaceState = () => {
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setSearches([]);
|
setSearches([]);
|
||||||
|
setWorkspaceItems([]);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -803,6 +922,9 @@ export default function App() {
|
|||||||
searchRunCountersRef.current.clear();
|
searchRunCountersRef.current.clear();
|
||||||
setComposer("");
|
setComposer("");
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
|
setIsChatSettingsOpen(false);
|
||||||
|
setAdditionalSystemPrompt("");
|
||||||
|
setEnabledTools([]);
|
||||||
setIsQuickQuestionOpen(false);
|
setIsQuickQuestionOpen(false);
|
||||||
setQuickPrompt("");
|
setQuickPrompt("");
|
||||||
setQuickSubmittedPrompt(null);
|
setQuickSubmittedPrompt(null);
|
||||||
@@ -820,15 +942,16 @@ export default function App() {
|
|||||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||||
setIsLoadingCollections(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
const nextWorkspaceItems = await listWorkspaceItems();
|
||||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||||
|
setWorkspaceItems(nextWorkspaceItems);
|
||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
setSearches(nextSearches);
|
setSearches(nextSearches);
|
||||||
|
|
||||||
setSelectedItem((current) => {
|
setSelectedItem((current) => {
|
||||||
const hasItem = (candidate: SidebarSelection | null) => {
|
const hasItem = (candidate: SidebarSelection | null) => {
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id);
|
return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (preferredSelection && hasItem(preferredSelection)) {
|
if (preferredSelection && hasItem(preferredSelection)) {
|
||||||
@@ -837,8 +960,8 @@ export default function App() {
|
|||||||
if (hasItem(current)) {
|
if (hasItem(current)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
const first = nextItems[0];
|
const first = nextWorkspaceItems[0];
|
||||||
return first ? { kind: first.kind, id: first.id } : null;
|
return first ? { kind: first.type, id: first.id } : null;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -866,6 +989,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshChatTools = async () => {
|
||||||
|
try {
|
||||||
|
const tools = await listChatTools();
|
||||||
|
setAvailableChatTools(tools);
|
||||||
|
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshActiveRuns = async () => {
|
const refreshActiveRuns = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getActiveRuns();
|
const data = await getActiveRuns();
|
||||||
@@ -918,7 +1056,7 @@ export default function App() {
|
|||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const preferredSelection = initialRouteSelectionRef.current;
|
const preferredSelection = initialRouteSelectionRef.current;
|
||||||
initialRouteSelectionRef.current = null;
|
initialRouteSelectionRef.current = null;
|
||||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -963,6 +1101,7 @@ export default function App() {
|
|||||||
|
|
||||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||||
|
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model.trim()) return;
|
if (model.trim()) return;
|
||||||
@@ -1017,6 +1156,7 @@ export default function App() {
|
|||||||
}, [quickPrompt, isQuickQuestionOpen]);
|
}, [quickPrompt, isQuickQuestionOpen]);
|
||||||
|
|
||||||
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||||
|
const transcriptViewKey = draftKind ? `draft:${draftKind}` : selectedKey ?? "empty";
|
||||||
const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null;
|
const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null;
|
||||||
const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
|
const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
|
||||||
const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
|
const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
|
||||||
@@ -1038,11 +1178,13 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const didViewChange = transcriptViewKeyRef.current !== transcriptViewKey;
|
||||||
|
transcriptViewKeyRef.current = transcriptViewKey;
|
||||||
shouldAutoScrollRef.current = true;
|
shouldAutoScrollRef.current = true;
|
||||||
if (!isSendingActiveChat) {
|
if (didViewChange && !pendingReplyScrollRef.current) {
|
||||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
}
|
}
|
||||||
}, [isSendingActiveChat, selectedKey]);
|
}, [transcriptViewKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedItemRef.current = selectedItem;
|
selectedItemRef.current = selectedItem;
|
||||||
@@ -1101,6 +1243,10 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (transcriptTailSpacerSettleFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current);
|
||||||
|
transcriptTailSpacerSettleFrameRef.current = null;
|
||||||
|
}
|
||||||
for (const controller of chatStreamAbortRefs.current.values()) {
|
for (const controller of chatStreamAbortRefs.current.values()) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
@@ -1129,6 +1275,16 @@ export default function App() {
|
|||||||
if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage);
|
if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage);
|
||||||
return messages.filter(isDisplayableMessage);
|
return messages.filter(isDisplayableMessage);
|
||||||
}, [messages, selectedChatPendingState]);
|
}, [messages, selectedChatPendingState]);
|
||||||
|
const displayMessagesLayoutKey = useMemo(
|
||||||
|
() => displayMessages.map((message) => `${message.id}:${message.content.length}`).join("|"),
|
||||||
|
[displayMessages]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSearchMode || isSendingActiveChat) return;
|
||||||
|
requestSettleTranscriptTailSpacer();
|
||||||
|
}, [displayMessagesLayoutKey, isSearchMode, isSendingActiveChat, selectedKey]);
|
||||||
|
|
||||||
const quickAnswerText = useMemo(() => {
|
const quickAnswerText = useMemo(() => {
|
||||||
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
|
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = quickQuestionMessages[index];
|
const message = quickQuestionMessages[index];
|
||||||
@@ -1162,6 +1318,19 @@ export default function App() {
|
|||||||
setModel(nextSelection.model);
|
setModel(nextSelection.model);
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftKind === "chat") {
|
||||||
|
setAdditionalSystemPrompt("");
|
||||||
|
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedItem?.kind !== "chat") return;
|
||||||
|
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
||||||
|
if (!chat) return;
|
||||||
|
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
|
||||||
|
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
|
||||||
|
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
const selectedTitle = useMemo(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (draftKind === "chat") return "New chat";
|
if (draftKind === "chat") return "New chat";
|
||||||
if (draftKind === "search") return "New search";
|
if (draftKind === "search") return "New search";
|
||||||
@@ -1465,6 +1634,11 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendChat = async (content: string, attachments: ChatAttachment[]): Promise<SidebarSelection> => {
|
const handleSendChat = async (content: string, attachments: ChatAttachment[]): Promise<SidebarSelection> => {
|
||||||
|
const selectedModel = model.trim();
|
||||||
|
if (!selectedModel) {
|
||||||
|
throw new Error("No model available for selected provider");
|
||||||
|
}
|
||||||
|
|
||||||
pendingReplyScrollRef.current = true;
|
pendingReplyScrollRef.current = true;
|
||||||
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
|
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
|
||||||
|
|
||||||
@@ -1496,6 +1670,7 @@ export default function App() {
|
|||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chatId });
|
setSelectedItem({ kind: "chat", id: chatId });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -1548,11 +1723,6 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedModel = model.trim();
|
|
||||||
if (!selectedModel) {
|
|
||||||
throw new Error("No model available for selected provider");
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatSummary = chats.find((chat) => chat.id === chatId);
|
const chatSummary = chats.find((chat) => chat.id === chatId);
|
||||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||||
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
||||||
@@ -1566,6 +1736,7 @@ export default function App() {
|
|||||||
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
|
||||||
setSelectedChat((current) => {
|
setSelectedChat((current) => {
|
||||||
if (!current || current.id !== updatedChat.id) return current;
|
if (!current || current.id !== updatedChat.id) return current;
|
||||||
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||||
@@ -1672,13 +1843,17 @@ export default function App() {
|
|||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||||
await refreshChat(chatId);
|
await refreshChat(chatId);
|
||||||
}
|
}
|
||||||
settleTranscriptTailSpacer();
|
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||||
|
requestSettleTranscriptTailSpacer();
|
||||||
|
}
|
||||||
return { kind: "chat", id: chatId };
|
return { kind: "chat", id: chatId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
|
pendingReplyScrollRef.current = false;
|
||||||
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1694,6 +1869,11 @@ export default function App() {
|
|||||||
searchId = search.id;
|
searchId = search.id;
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
setSelectedItem({ kind: "search", id: searchId });
|
setSelectedItem({ kind: "search", id: searchId });
|
||||||
|
setSearches((current) => {
|
||||||
|
const withoutExisting = current.filter((existing) => existing.id !== search.id);
|
||||||
|
return [search, ...withoutExisting];
|
||||||
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(search)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchId) {
|
if (!searchId) {
|
||||||
@@ -1939,6 +2119,9 @@ export default function App() {
|
|||||||
chatStreamAbortRefs.current.delete(chatId);
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
requestSettleTranscriptTailSpacer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2064,6 +2247,7 @@ export default function App() {
|
|||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chat.id });
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -2239,6 +2423,7 @@ export default function App() {
|
|||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chat.id });
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -2288,6 +2473,10 @@ export default function App() {
|
|||||||
sentTarget = await handleSendChat(content, attachments);
|
sentTarget = await handleSendChat(content, attachments);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!sentAsSearch) {
|
||||||
|
pendingReplyScrollRef.current = false;
|
||||||
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
@@ -2449,7 +2638,7 @@ export default function App() {
|
|||||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||||
@@ -2467,11 +2656,15 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span>
|
<span className="col-start-2 flex min-w-0 items-center gap-2">
|
||||||
|
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
|
||||||
|
{initiatedLabel ? (
|
||||||
|
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
|
||||||
|
{initiatedLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{initiatedLabel ? (
|
|
||||||
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2512,9 +2705,11 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
disabled={isActiveSelectionSending}
|
disabled={isActiveSelectionSending}
|
||||||
>
|
>
|
||||||
<option value="openai">OpenAI</option>
|
{providerOptions.map((candidate) => (
|
||||||
<option value="anthropic">Anthropic</option>
|
<option key={candidate} value={candidate}>
|
||||||
<option value="xai">xAI</option>
|
{getProviderLabel(candidate)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
options={providerModelOptions}
|
options={providerModelOptions}
|
||||||
@@ -2547,6 +2742,9 @@ export default function App() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
shouldAutoScrollRef.current = distanceFromBottom < 96;
|
shouldAutoScrollRef.current = distanceFromBottom < 96;
|
||||||
|
if (!isSearchMode && !isSendingActiveChat && distanceFromBottom > 0) {
|
||||||
|
settleTranscriptTailSpacer();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
@@ -2758,9 +2956,11 @@ export default function App() {
|
|||||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||||
aria-label="Quick question provider"
|
aria-label="Quick question provider"
|
||||||
>
|
>
|
||||||
<option value="openai">OpenAI</option>
|
{providerOptions.map((candidate) => (
|
||||||
<option value="anthropic">Anthropic</option>
|
<option key={candidate} value={candidate}>
|
||||||
<option value="xai">xAI</option>
|
{getProviderLabel(candidate)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
options={quickProviderModelOptions}
|
options={quickProviderModelOptions}
|
||||||
|
|||||||
@@ -206,17 +206,31 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-content code {
|
.md-content code {
|
||||||
background: hsl(288 22% 23%);
|
background: hsl(249 40% 10% / 0.78);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.3rem;
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content pre {
|
.md-content pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 0.5rem;
|
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||||
background: hsl(287 28% 13%);
|
border-radius: 0.625rem;
|
||||||
padding: 0.6rem 0.75rem;
|
background: hsl(249 40% 10% / 0.82);
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre code {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content a {
|
.md-content a {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type ChatSummary = {
|
|||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
lastUsedModel: string | null;
|
lastUsedModel: string | null;
|
||||||
|
additionalSystemPrompt: string | null;
|
||||||
|
enabledTools: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchSummary = {
|
export type SearchSummary = {
|
||||||
@@ -17,6 +19,16 @@ export type SearchSummary = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
|
type: "chat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWorkspaceItem = SearchSummary & {
|
||||||
|
type: "search";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -48,6 +60,8 @@ export type ChatDetail = {
|
|||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
lastUsedModel: string | null;
|
lastUsedModel: string | null;
|
||||||
|
additionalSystemPrompt: string | null;
|
||||||
|
enabledTools: string[] | null;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,7 +141,7 @@ export type CompletionRequestMessage = {
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = "openai" | "anthropic" | "xai";
|
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
@@ -136,7 +150,12 @@ export type ProviderModelInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ModelCatalogResponse = {
|
export type ModelCatalogResponse = {
|
||||||
providers: Record<Provider, ProviderModelInfo>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatToolInfo = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActiveRunsResponse = {
|
export type ActiveRunsResponse = {
|
||||||
@@ -164,6 +183,8 @@ type CreateChatRequest = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
messages?: CompletionRequestMessage[];
|
messages?: CompletionRequestMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +235,11 @@ export async function listChats() {
|
|||||||
return data.chats;
|
return data.chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceItems() {
|
||||||
|
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifySession() {
|
export async function verifySession() {
|
||||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||||
}
|
}
|
||||||
@@ -222,6 +248,11 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listChatTools() {
|
||||||
|
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
||||||
|
return data.tools;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getActiveRuns() {
|
export async function getActiveRuns() {
|
||||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||||
}
|
}
|
||||||
@@ -248,6 +279,14 @@ export async function updateChatTitle(chatId: string, title: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) {
|
||||||
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
export async function suggestChatTitle(body: { chatId: string; content: string }) {
|
export async function suggestChatTitle(body: { chatId: string; content: string }) {
|
||||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -554,6 +593,9 @@ export async function runCompletion(body: {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
}) {
|
}) {
|
||||||
return api<CompletionResponse>("/v1/chat-completions", {
|
return api<CompletionResponse>("/v1/chat-completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -568,6 +610,9 @@ export async function runCompletionStream(
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
|
additionalSystemPrompt?: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
},
|
},
|
||||||
handlers: CompletionStreamHandlers,
|
handlers: CompletionStreamHandlers,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
|
|||||||
Reference in New Issue
Block a user