Compare commits
1 Commits
master
...
7d69cb4979
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d69cb4979 |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -17,11 +17,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
122
docs/api/rest.md
122
docs/api/rest.md
@@ -40,24 +40,6 @@ Chat upload limits:
|
||||
```
|
||||
- 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.
|
||||
|
||||
## Chat Tools
|
||||
|
||||
### `GET /v1/chat-tools`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{ "name": "web_search", "description": "..." },
|
||||
{ "name": "fetch_url", "description": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
|
||||
@@ -75,49 +57,6 @@ Behavior notes:
|
||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||
- 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",
|
||||
"starred": true,
|
||||
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
"lastUsedModel": "gpt-4.1-mini",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"starred": false,
|
||||
"starredAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -130,8 +69,6 @@ Behavior notes:
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"additionalSystemPrompt": "optional stored system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system|user|assistant|tool",
|
||||
@@ -147,29 +84,13 @@ Behavior notes:
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
### `PATCH /v1/chats/:chatId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Body: `{ "title": string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the chat transcript or chat `updatedAt`.
|
||||
|
||||
### `POST /v1/chats/title/suggest`
|
||||
- Body:
|
||||
```json
|
||||
@@ -182,8 +103,7 @@ Behavior notes:
|
||||
|
||||
Behavior notes:
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -262,8 +182,6 @@ Notes:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -283,8 +201,6 @@ Notes:
|
||||
Behavior notes:
|
||||
- If `chatId` is present, server validates chat existence.
|
||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||
@@ -314,7 +230,7 @@ Behavior notes:
|
||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- `anthropic` currently runs without server-managed tool calls.
|
||||
|
||||
## Searches
|
||||
@@ -323,24 +239,8 @@ Behavior notes:
|
||||
- Response: `{ "searches": SearchSummary[] }`
|
||||
|
||||
### `POST /v1/searches`
|
||||
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
|
||||
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
|
||||
|
||||
Behavior notes:
|
||||
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
|
||||
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
|
||||
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
|
||||
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
|
||||
|
||||
### `PATCH /v1/searches/:searchId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Body: `{ "title"?: string, "query"?: string }`
|
||||
- Response: `{ "search": SearchSummary }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the search results or search `updatedAt`.
|
||||
|
||||
### `DELETE /v1/searches/:searchId`
|
||||
- Response: `{ "deleted": true }`
|
||||
@@ -414,14 +314,10 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
"lastUsedModel": "string|null"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -466,21 +362,17 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [Message]
|
||||
}
|
||||
```
|
||||
|
||||
`SearchSummary`
|
||||
```json
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
||||
```
|
||||
|
||||
`SearchDetail`
|
||||
@@ -491,8 +383,6 @@ Behavior notes:
|
||||
"query": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"requestId": "...",
|
||||
"latencyMs": 123,
|
||||
"error": null,
|
||||
|
||||
@@ -49,8 +49,6 @@ Authentication:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -62,8 +60,6 @@ Notes:
|
||||
- If `chatId` is provided, backend validates it exists.
|
||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||
@@ -91,8 +87,6 @@ Event order:
|
||||
3. Zero or more `delta`
|
||||
4. Exactly one terminal event: `done` or `error`
|
||||
|
||||
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
|
||||
|
||||
### `meta`
|
||||
|
||||
```json
|
||||
@@ -117,19 +111,6 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
|
||||
### `tool_call`
|
||||
|
||||
```json
|
||||
{
|
||||
"toolCallId": "call_123",
|
||||
"name": "web_search",
|
||||
"status": "initiated",
|
||||
"summary": "Searching web for 'latest CPI release'.",
|
||||
"args": { "query": "latest CPI release" },
|
||||
"startedAt": "2026-03-02T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Terminal tool-call event:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolCallId": "call_123",
|
||||
@@ -140,12 +121,11 @@ Terminal tool-call event:
|
||||
"startedAt": "2026-03-02T10:00:00.000Z",
|
||||
"completedAt": "2026-03-02T10:00:00.820Z",
|
||||
"durationMs": 820,
|
||||
"error": null,
|
||||
"resultPreview": "{\"ok\":true,...}"
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
|
||||
|
||||
### `done`
|
||||
|
||||
```json
|
||||
@@ -194,8 +174,7 @@ Backend database remains source of truth.
|
||||
|
||||
For persisted streams:
|
||||
- Client may optimistically render accumulated `delta` text.
|
||||
- Backend emits initiated tool-call events without persisting them.
|
||||
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
|
||||
On successful persisted completion:
|
||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||
FASTLANE_USER=you@example.com
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||
FASTLANE_HIDE_CHANGELOG=1
|
||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||
|
||||
# Optional App Store Connect API key settings for non-interactive upload and
|
||||
# TestFlight build-number lookup.
|
||||
APP_STORE_CONNECT_API_KEY_ID=
|
||||
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||
APP_STORE_CONNECT_API_KEY_PATH=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||
|
||||
# Optional deployment overrides.
|
||||
SYBIL_BUILD_NUMBER=
|
||||
SYBIL_VERSION_TAG=
|
||||
11
ios/.gitignore
vendored
11
ios/.gitignore
vendored
@@ -1,11 +1,2 @@
|
||||
*.xcodeproj
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/
|
||||
fastlane/test_output/
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: "1.10"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
MARKETING_VERSION: 1.7
|
||||
CURRENT_PROJECT_VERSION: 8
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.227"
|
||||
@@ -44,11 +44,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
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] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
return response.chats
|
||||
@@ -74,26 +69,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -138,16 +113,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
let response = try await request(
|
||||
"/v1/searches/\(searchID)/star",
|
||||
method: "PATCH",
|
||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
||||
responseType: SearchCreateResponse.self
|
||||
)
|
||||
return response.search
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -661,7 +626,6 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
@@ -671,14 +635,6 @@ private struct ChatCreateBody: Encodable {
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
private struct SearchCreateBody: Encodable {
|
||||
var title: String?
|
||||
var query: String?
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
func listChats() async throws -> [ChatSummary]
|
||||
func createChat(
|
||||
title: String?,
|
||||
@@ -11,15 +10,12 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||
func listSearches() async throws -> [SearchSummary]
|
||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||
func getSearch(searchID: String) async throws -> SearchDetail
|
||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
||||
func deleteSearch(searchID: String) async throws
|
||||
func listModels() async throws -> ModelCatalogResponse
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||
|
||||
@@ -7,55 +7,77 @@ struct SybilChatTranscriptView: View {
|
||||
var isSending: Bool
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
var tailSpacerHeight: CGFloat = 0
|
||||
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
|
||||
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
|
||||
|
||||
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||
private var hasPendingAssistant: Bool {
|
||||
messages.contains { message in
|
||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
ForEach(messages.reversed()) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isStreamingPendingAssistant(message) {
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(
|
||||
key: SybilPendingAssistantHeightPreferenceKey.self,
|
||||
value: proxy.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.top, 24)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||
.padding(.bottom, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
onViewportHeightChange?(proxy.size.height)
|
||||
}
|
||||
.onChange(of: bottomPinRequestID) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: true)
|
||||
.onChange(of: proxy.size.height) { _, height in
|
||||
onViewportHeightChange?(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
|
||||
onPendingAssistantHeightChange?(height)
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
|
||||
isSending && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||
let action = {
|
||||
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||
}
|
||||
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.18), action)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +177,6 @@ private struct MessageBubble: View {
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
@@ -207,22 +223,11 @@ private struct ToolCallActivityChip: View {
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
visualState == .failed
|
||||
}
|
||||
|
||||
private var visualState: VisualState {
|
||||
switch (metadata.status ?? "").lowercased() {
|
||||
case "failed":
|
||||
return .failed
|
||||
case "initiated":
|
||||
return .initiated
|
||||
default:
|
||||
return .completed
|
||||
}
|
||||
(metadata.status ?? "").lowercased() == "failed"
|
||||
}
|
||||
|
||||
private var detailLabel: String {
|
||||
var pieces: [String] = [stateLabel]
|
||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||
pieces.append("\(durationMs) ms")
|
||||
}
|
||||
@@ -234,14 +239,14 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(iconColor.opacity(0.13))
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(iconColor)
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
@@ -255,7 +260,7 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(spacing: 6) {
|
||||
Text(toolLabel)
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(iconColor.opacity(0.90))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detailLabel)
|
||||
@@ -270,45 +275,12 @@ private struct ToolCallActivityChip: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(backgroundGradient)
|
||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
}
|
||||
|
||||
private var stateLabel: String {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return "Failed"
|
||||
case .initiated:
|
||||
return "Running"
|
||||
case .completed:
|
||||
return "Completed"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.danger
|
||||
case .initiated:
|
||||
return SybilTheme.warning
|
||||
case .completed:
|
||||
return SybilTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundGradient: LinearGradient {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.failedToolCallGradient
|
||||
case .initiated:
|
||||
return SybilTheme.runningToolCallGradient
|
||||
case .completed:
|
||||
return SybilTheme.toolCallGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
@@ -168,87 +166,6 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: 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 starred = false
|
||||
public var starredAt: 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.starred = chat.starred
|
||||
self.starredAt = chat.starredAt
|
||||
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.starred = search.starred
|
||||
self.starredAt = search.starredAt
|
||||
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,
|
||||
starred: starred,
|
||||
starredAt: starredAt,
|
||||
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,
|
||||
starred: starred,
|
||||
starredAt: starredAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
@@ -391,8 +308,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var title: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var initiatedProvider: Provider?
|
||||
public var initiatedModel: String?
|
||||
public var lastUsedProvider: Provider?
|
||||
@@ -431,8 +346,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
public var query: String?
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var starred = false
|
||||
public var starredAt: Date?
|
||||
public var requestId: String?
|
||||
public var latencyMs: Int?
|
||||
public var error: String?
|
||||
@@ -514,8 +427,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
|
||||
public var summary: String
|
||||
public var args: [String: JSONValue]
|
||||
public var startedAt: String
|
||||
public var completedAt: String?
|
||||
public var durationMs: Int?
|
||||
public var completedAt: String
|
||||
public var durationMs: Int
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
}
|
||||
@@ -611,10 +524,6 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
struct ChatDetailResponse: Codable {
|
||||
var chat: ChatDetail
|
||||
}
|
||||
|
||||
@@ -219,11 +219,6 @@ struct SybilQuickQuestionView: View {
|
||||
}
|
||||
|
||||
private func submitQuestion() {
|
||||
guard viewModel.canSendQuickQuestion else {
|
||||
return
|
||||
}
|
||||
|
||||
promptFocused = false
|
||||
_ = viewModel.sendQuickQuestion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,22 +111,8 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
|
||||
private var isRenameAlertPresented: Binding<Bool> {
|
||||
Binding {
|
||||
renameTarget != nil
|
||||
} set: { isPresented in
|
||||
if !isPresented {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView()
|
||||
@@ -160,23 +146,6 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
||||
}
|
||||
} label: {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteItem(item.selection)
|
||||
@@ -190,31 +159,13 @@ struct SybilSidebarItemList: View {
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||
TextField("Title", text: $renameTitle)
|
||||
Button("Cancel", role: .cancel) {
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
Button("Save") {
|
||||
let target = renameTarget
|
||||
let title = renameTitle
|
||||
renameTarget = nil
|
||||
renameTitle = ""
|
||||
|
||||
if let target, case let .chat(chatID) = target.selection {
|
||||
Task {
|
||||
await viewModel.renameChat(chatID: chatID, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarRow: View {
|
||||
@@ -253,12 +204,6 @@ struct SybilSidebarRow: View {
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
if item.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if item.isRunning {
|
||||
|
||||
@@ -78,7 +78,6 @@ enum SybilTheme {
|
||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
|
||||
|
||||
@MainActor static func applySystemAppearance() {
|
||||
let navAppearance = UINavigationBarAppearance()
|
||||
@@ -187,17 +186,6 @@ enum SybilTheme {
|
||||
)
|
||||
}
|
||||
|
||||
static var runningToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
|
||||
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
static var failedToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
|
||||
@@ -34,8 +34,6 @@ struct SidebarItem: Identifiable, Hashable {
|
||||
var kind: Kind
|
||||
var title: String
|
||||
var updatedAt: Date
|
||||
var starred: Bool
|
||||
var starredAt: Date?
|
||||
var initiatedLabel: String?
|
||||
var isRunning: Bool
|
||||
}
|
||||
@@ -97,7 +95,6 @@ final class SybilViewModel {
|
||||
|
||||
var chats: [ChatSummary] = []
|
||||
var searches: [SearchSummary] = []
|
||||
var workspaceItems: [WorkspaceItem] = []
|
||||
|
||||
var selectedItem: SidebarSelection?
|
||||
var selectedChat: ChatDetail?
|
||||
@@ -107,7 +104,6 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
var errorMessage: String?
|
||||
|
||||
var composer = ""
|
||||
@@ -392,12 +388,10 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var sidebarItems: [SidebarItem] {
|
||||
workspaceItems.map { item in
|
||||
switch item.type {
|
||||
case .chat:
|
||||
let chatItems: [SidebarItem] = chats.map { chat in
|
||||
let initiatedLabel: String?
|
||||
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = item.initiatedProvider {
|
||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = chat.initiatedProvider {
|
||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||
} else {
|
||||
initiatedLabel = model
|
||||
@@ -407,29 +401,27 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(item.id),
|
||||
selection: .chat(chat.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: item.title, messages: nil),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
title: chatTitle(title: chat.title, messages: nil),
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
isRunning: isChatRowRunning(chat.id)
|
||||
)
|
||||
}
|
||||
|
||||
case .search:
|
||||
return SidebarItem(
|
||||
selection: .search(item.id),
|
||||
let searchItems: [SidebarItem] = searches.map { search in
|
||||
SidebarItem(
|
||||
selection: .search(search.id),
|
||||
kind: .search,
|
||||
title: searchTitle(title: item.title, query: item.query),
|
||||
updatedAt: item.updatedAt,
|
||||
starred: item.starred,
|
||||
starredAt: item.starredAt,
|
||||
title: searchTitle(title: search.title, query: search.query),
|
||||
updatedAt: search.updatedAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(item.id)
|
||||
isRunning: isSearchRowRunning(search.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
var selectedChatSummary: ChatSummary? {
|
||||
@@ -510,7 +502,6 @@ final class SybilViewModel {
|
||||
authMode = nil
|
||||
chats = []
|
||||
searches = []
|
||||
workspaceItems = []
|
||||
selectedItem = .settings
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -680,7 +671,6 @@ final class SybilViewModel {
|
||||
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(
|
||||
@@ -688,8 +678,6 @@ final class SybilViewModel {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -860,57 +848,6 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func renameChat(chatID: String, title: String) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedTitle.isEmpty else {
|
||||
errorMessage = "Enter a chat title."
|
||||
return
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||
applyChatSummary(updated, moveToFront: true)
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
guard case .settings = selection else {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
||||
applyChatSummary(updated, moveToFront: false)
|
||||
case let .search(searchID):
|
||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||
applySearchSummary(updated, moveToFront: false)
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAfterSettingsChange() async {
|
||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||
settings.persist()
|
||||
@@ -974,23 +911,6 @@ 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 {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let attachments = composerAttachments
|
||||
@@ -1097,7 +1017,6 @@ final class SybilViewModel {
|
||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
isCreatingSearchChat = false
|
||||
return
|
||||
}
|
||||
@@ -1109,7 +1028,6 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedSearch = nil
|
||||
@@ -1187,7 +1105,7 @@ final class SybilViewModel {
|
||||
break
|
||||
|
||||
case let .toolCall(payload):
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
insertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -1213,16 +1131,18 @@ final class SybilViewModel {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
||||
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
|
||||
do {
|
||||
@@ -1239,7 +1159,7 @@ final class SybilViewModel {
|
||||
if case .settings = selectedItem {
|
||||
nextSelection = .settings
|
||||
} else if let currentSelection = selectedItem,
|
||||
hasSelection(currentSelection, chats: chats, searches: searches) {
|
||||
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) {
|
||||
nextSelection = currentSelection
|
||||
} else {
|
||||
nextSelection = sidebarItems.first?.selection
|
||||
@@ -1311,16 +1231,18 @@ final class SybilViewModel {
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
errorMessage = nil
|
||||
|
||||
@@ -1338,10 +1260,10 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
if let preferredSelection,
|
||||
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
||||
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
||||
selectedItem = preferredSelection
|
||||
} else if let existing = selectedItem,
|
||||
hasSelection(existing, chats: chats, searches: searches) {
|
||||
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
||||
selectedItem = existing
|
||||
} else {
|
||||
selectedItem = sidebarItems.first?.selection
|
||||
@@ -1354,9 +1276,7 @@ final class SybilViewModel {
|
||||
attachToVisibleActiveRunIfNeeded()
|
||||
}
|
||||
} catch {
|
||||
if isCancellation(error) {
|
||||
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
if shouldSuppressInactiveTransportError(error) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||
} else {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
@@ -1435,75 +1355,6 @@ final class SybilViewModel {
|
||||
serverActiveSearchIDs = Set(activeRuns.searches)
|
||||
}
|
||||
|
||||
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
|
||||
workspaceItems = items
|
||||
chats = items.compactMap(\.chatSummary)
|
||||
searches = items.compactMap(\.searchSummary)
|
||||
}
|
||||
|
||||
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
|
||||
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
chats.remove(at: existingIndex)
|
||||
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
|
||||
} else {
|
||||
chats.insert(chat, at: 0)
|
||||
}
|
||||
|
||||
upsertWorkspaceChat(chat, moveToFront: moveToFront)
|
||||
|
||||
if selectedChat?.id == chat.id {
|
||||
selectedChat?.title = chat.title
|
||||
selectedChat?.updatedAt = chat.updatedAt
|
||||
selectedChat?.starred = chat.starred
|
||||
selectedChat?.starredAt = chat.starredAt
|
||||
selectedChat?.initiatedProvider = chat.initiatedProvider
|
||||
selectedChat?.initiatedModel = chat.initiatedModel
|
||||
selectedChat?.lastUsedProvider = chat.lastUsedProvider
|
||||
selectedChat?.lastUsedModel = chat.lastUsedModel
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
|
||||
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
|
||||
searches.remove(at: existingIndex)
|
||||
searches.insert(search, at: moveToFront ? 0 : existingIndex)
|
||||
} else {
|
||||
searches.insert(search, at: 0)
|
||||
}
|
||||
|
||||
upsertWorkspaceSearch(search, moveToFront: moveToFront)
|
||||
|
||||
if selectedSearch?.id == search.id {
|
||||
selectedSearch?.title = search.title
|
||||
selectedSearch?.query = search.query
|
||||
selectedSearch?.updatedAt = search.updatedAt
|
||||
selectedSearch?.starred = search.starred
|
||||
selectedSearch?.starredAt = search.starredAt
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1700,10 +1551,6 @@ final class SybilViewModel {
|
||||
isLoadingSelection = false
|
||||
}
|
||||
|
||||
private func requestChatBottomPin() {
|
||||
chatBottomPinRequestID += 1
|
||||
}
|
||||
|
||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||
isLoadingSelection = true
|
||||
let task = Task { [weak self] in
|
||||
@@ -1757,7 +1604,6 @@ final class SybilViewModel {
|
||||
}
|
||||
selectedChat = chat
|
||||
selectedSearch = nil
|
||||
requestChatBottomPin()
|
||||
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
@@ -1830,7 +1676,6 @@ final class SybilViewModel {
|
||||
} else {
|
||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if chatID == nil {
|
||||
let created = try await client.createChat(title: nil)
|
||||
@@ -1841,7 +1686,6 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == created.id })
|
||||
chats.insert(created, at: 0)
|
||||
upsertWorkspaceChat(created)
|
||||
|
||||
if shouldShowCreatedChat {
|
||||
draftKind = nil
|
||||
@@ -1852,8 +1696,6 @@ final class SybilViewModel {
|
||||
title: created.title,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
initiatedProvider: created.initiatedProvider,
|
||||
initiatedModel: created.initiatedModel,
|
||||
lastUsedProvider: created.lastUsedProvider,
|
||||
@@ -1878,7 +1720,6 @@ final class SybilViewModel {
|
||||
if let draftPending = pendingDraftChatState {
|
||||
pendingDraftChatState = nil
|
||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
||||
requestChatBottomPin()
|
||||
} else if pendingChatStates[chatID] == nil {
|
||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
||||
} else {
|
||||
@@ -1915,7 +1756,17 @@ final class SybilViewModel {
|
||||
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||
await MainActor.run {
|
||||
self.applyChatSummary(updated, moveToFront: false)
|
||||
self.chats = self.chats.map { existing in
|
||||
if existing.id == updated.id {
|
||||
return updated
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
if self.selectedChat?.id == updated.id {
|
||||
self.selectedChat?.title = updated.title
|
||||
self.selectedChat?.updatedAt = updated.updatedAt
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
||||
@@ -2014,7 +1865,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
case let .toolCall(payload):
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
insertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -2048,7 +1899,6 @@ final class SybilViewModel {
|
||||
|
||||
searches.removeAll(where: { $0.id == created.id })
|
||||
searches.insert(created, at: 0)
|
||||
upsertWorkspaceSearch(created)
|
||||
|
||||
if shouldShowCreatedSearch {
|
||||
draftKind = nil
|
||||
@@ -2074,8 +1924,6 @@ final class SybilViewModel {
|
||||
query: query,
|
||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
starred: currentSelectedSearch?.starred ?? false,
|
||||
starredAt: currentSelectedSearch?.starredAt,
|
||||
requestId: nil,
|
||||
latencyMs: nil,
|
||||
error: nil,
|
||||
@@ -2230,14 +2078,12 @@ final class SybilViewModel {
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
}
|
||||
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
return
|
||||
}
|
||||
|
||||
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||
pendingChatStates[chatID] = pending
|
||||
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2252,9 +2098,8 @@ final class SybilViewModel {
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2266,8 +2111,8 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
|
||||
var metadataObject: [String: JSONValue] = [
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
||||
let metadata: JSONValue = .object([
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string(payload.toolCallId),
|
||||
"toolName": .string(payload.name),
|
||||
@@ -2275,26 +2120,19 @@ final class SybilViewModel {
|
||||
"summary": .string(payload.summary),
|
||||
"args": .object(payload.args),
|
||||
"startedAt": .string(payload.startedAt),
|
||||
"completedAt": .string(payload.completedAt),
|
||||
"durationMs": .number(Double(payload.durationMs)),
|
||||
"error": payload.error.map { .string($0) } ?? .null,
|
||||
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
||||
]
|
||||
|
||||
if let completedAt = payload.completedAt {
|
||||
metadataObject["completedAt"] = .string(completedAt)
|
||||
}
|
||||
if let durationMs = payload.durationMs {
|
||||
metadataObject["durationMs"] = .number(Double(durationMs))
|
||||
}
|
||||
|
||||
let metadata: JSONValue = .object(metadataObject)
|
||||
])
|
||||
|
||||
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Ran tool '\(payload.name)'."
|
||||
: payload.summary
|
||||
|
||||
return Message(
|
||||
id: id ?? "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
|
||||
id: "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: Date(),
|
||||
role: .tool,
|
||||
content: summary,
|
||||
name: payload.name,
|
||||
@@ -2302,19 +2140,6 @@ final class SybilViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func toolCallDate(from value: String?) -> Date? {
|
||||
guard let value else { return nil }
|
||||
let fractionalFormatter = ISO8601DateFormatter()
|
||||
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractionalFormatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: value)
|
||||
}
|
||||
|
||||
private var currentChatID: String? {
|
||||
if draftKind == .chat {
|
||||
return nil
|
||||
|
||||
@@ -26,6 +26,10 @@ struct SybilWorkspaceView: View {
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
@State private var transcriptViewportHeight: CGFloat = 0
|
||||
@State private var pendingAssistantBaselineHeight: CGFloat?
|
||||
@State private var newChatSwipeOffset: CGFloat = 0
|
||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||
@@ -38,6 +42,10 @@ struct SybilWorkspaceView: View {
|
||||
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||
private let composerOverlayContentInset: CGFloat = 112
|
||||
|
||||
private var visibleTranscriptTailSpacerHeight: CGFloat {
|
||||
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
|
||||
}
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -145,6 +153,17 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.onChange(of: transcriptScrollContextID) { _, _ in
|
||||
handleTranscriptContextChange()
|
||||
}
|
||||
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
|
||||
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if message != nil && !viewModel.isSendingVisibleChat {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
}
|
||||
.task(id: composerFocusPolicyID) {
|
||||
await applyComposerFocusPolicy()
|
||||
}
|
||||
@@ -195,7 +214,13 @@ struct SybilWorkspaceView: View {
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||
onViewportHeightChange: { height in
|
||||
handleTranscriptViewportHeightChange(height)
|
||||
},
|
||||
onPendingAssistantHeightChange: { height in
|
||||
handlePendingAssistantHeightChange(height)
|
||||
}
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -233,7 +258,13 @@ struct SybilWorkspaceView: View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
customWorkspaceNavigationTitle
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
workspaceNavigationTrailingControl
|
||||
}
|
||||
@@ -246,32 +277,6 @@ 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
|
||||
private var workspaceNavigationLeadingControl: some View {
|
||||
switch navigationLeadingControl {
|
||||
@@ -306,6 +311,86 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptContextChange() {
|
||||
resetTranscriptTailSpacer(animated: false)
|
||||
}
|
||||
|
||||
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
|
||||
guard !viewModel.isSearchMode else {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if isSending {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if wasSending {
|
||||
if viewModel.errorMessage != nil {
|
||||
resetTranscriptTailSpacer(animated: true)
|
||||
}
|
||||
pendingAssistantBaselineHeight = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
|
||||
transcriptViewportHeight = height
|
||||
|
||||
if viewModel.isSendingVisibleChat,
|
||||
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
|
||||
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
if pendingAssistantBaselineHeight == nil {
|
||||
pendingAssistantBaselineHeight = height
|
||||
}
|
||||
|
||||
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: transcriptTailSpacerTargetHeight,
|
||||
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
|
||||
currentAssistantHeight: height
|
||||
)
|
||||
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
|
||||
setTranscriptTailSpacer(nextHeight, animated: false)
|
||||
}
|
||||
|
||||
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
|
||||
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
|
||||
transcriptTailSpacerTargetHeight = targetHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(targetHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func resetTranscriptTailSpacer(animated: Bool) {
|
||||
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
|
||||
pendingAssistantBaselineHeight = nil
|
||||
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
|
||||
}
|
||||
|
||||
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
|
||||
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
|
||||
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = {
|
||||
transcriptTailSpacerHeight = nextHeight
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.22), update)
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
@@ -723,8 +808,14 @@ struct SybilWorkspaceView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !viewModel.isSearchMode {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
}
|
||||
#endif
|
||||
|
||||
Task {
|
||||
@@ -790,6 +881,37 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum SybilTranscriptTailSpacer {
|
||||
static let minimumHeight: CGFloat = 20
|
||||
static let replyBufferMin: CGFloat = 288
|
||||
static let replyBufferMax: CGFloat = 576
|
||||
static let replyBufferViewportRatio: CGFloat = 0.52
|
||||
|
||||
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
|
||||
guard viewportHeight > 0 else {
|
||||
return replyBufferMin
|
||||
}
|
||||
|
||||
return min(
|
||||
replyBufferMax,
|
||||
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
|
||||
)
|
||||
}
|
||||
|
||||
static func clampedHeight(_ height: CGFloat) -> CGFloat {
|
||||
max(minimumHeight, height.rounded(.up))
|
||||
}
|
||||
|
||||
static func placeholderHeight(
|
||||
targetHeight: CGFloat,
|
||||
baselineAssistantHeight: CGFloat,
|
||||
currentAssistantHeight: CGFloat
|
||||
) -> CGFloat {
|
||||
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
|
||||
return clampedHeight(targetHeight - consumedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatSwipeMetrics {
|
||||
static let referenceWidth: CGFloat = 390
|
||||
static let horizontalActivationDistance: CGFloat = 18
|
||||
|
||||
@@ -4,14 +4,10 @@ import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listWorkspaceItems = 0
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var updateChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
var updateSearchStar = 0
|
||||
var getSearch = 0
|
||||
var getActiveRuns = 0
|
||||
var runCompletionStream = 0
|
||||
@@ -31,21 +27,15 @@ private struct UnexpectedClientCall: Error {}
|
||||
private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let workspaceItemsResponse: [WorkspaceItem]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
private let updateSearchStarResponses: [String: SearchSummary]
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
|
||||
private var snapshot = MockClientCallSnapshot()
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||
private var completionStreamNetworkErrorMessage: String?
|
||||
@@ -63,28 +53,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
self.updateSearchStarResponses = updateSearchStarResponses
|
||||
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 {
|
||||
snapshot
|
||||
}
|
||||
@@ -107,11 +85,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||
listChatsDelayNanoseconds = chats
|
||||
listSearchesDelayNanoseconds = searches
|
||||
}
|
||||
|
||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -147,20 +120,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
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] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||
}
|
||||
return chatsResponse
|
||||
}
|
||||
|
||||
@@ -194,22 +155,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
return detail
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
snapshot.updateChatTitle += 1
|
||||
guard let summary = updateChatTitleResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||
snapshot.updateChatStar += 1
|
||||
guard let summary = updateChatStarResponses[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -220,9 +165,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
if listSearchesDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||
}
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
@@ -245,14 +187,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
||||
snapshot.updateSearchStar += 1
|
||||
guard let summary = updateSearchStarResponses[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -442,41 +376,13 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 1)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.listChats == 1)
|
||||
#expect(snapshot.listSearches == 1)
|
||||
#expect(snapshot.getChat == 0)
|
||||
#expect(snapshot.getSearch == 0)
|
||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_050)
|
||||
let chat = makeChatSummary(id: "chat-cancelled", date: date)
|
||||
let search = makeSearchSummary(id: "search-cancelled", date: date)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search]
|
||||
)
|
||||
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
|
||||
let refreshTask = Task {
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
refreshTask.cancel()
|
||||
await refreshTask.value
|
||||
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(!viewModel.isLoadingCollections)
|
||||
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
|
||||
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
@@ -490,83 +396,10 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
||||
let renamed = ChatSummary(
|
||||
id: "chat-rename",
|
||||
title: "Renamed chat",
|
||||
createdAt: date,
|
||||
updatedAt: date.addingTimeInterval(60),
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini"
|
||||
)
|
||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [original],
|
||||
updateChatTitleResponses: ["chat-rename": renamed]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [original]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
|
||||
viewModel.selectedItem = .chat("chat-rename")
|
||||
viewModel.selectedChat = detail
|
||||
|
||||
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatTitle == 1)
|
||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||
let chat = makeChatSummary(id: "chat-star", date: date)
|
||||
let search = makeSearchSummary(id: "search-star", date: date)
|
||||
var starredChat = chat
|
||||
starredChat.starred = true
|
||||
starredChat.starredAt = date.addingTimeInterval(5)
|
||||
var starredSearch = search
|
||||
starredSearch.starred = true
|
||||
starredSearch.starredAt = date.addingTimeInterval(10)
|
||||
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search],
|
||||
updateChatStarResponses: ["chat-star": starredChat],
|
||||
updateSearchStarResponses: ["search-star": starredSearch]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [chat]
|
||||
viewModel.searches = [search]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
|
||||
|
||||
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
|
||||
await viewModel.setItemStarred(.search("search-star"), starred: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.updateChatStar == 1)
|
||||
#expect(snapshot.updateSearchStar == 1)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
|
||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -582,7 +415,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getSearch == 1)
|
||||
@@ -683,37 +515,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await sendTask.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_245)
|
||||
let chat = makeChatSummary(id: "chat-pin", date: date)
|
||||
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: ["chat-pin": detail]
|
||||
)
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "partial ")),
|
||||
.delta(CompletionStreamDelta(text: "response")),
|
||||
.done(CompletionStreamDone(text: "partial response"))
|
||||
])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [chat]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
|
||||
viewModel.selectedItem = .chat("chat-pin")
|
||||
viewModel.selectedChat = detail
|
||||
viewModel.composer = "continue"
|
||||
|
||||
let initialPinRequestID = viewModel.chatBottomPinRequestID
|
||||
await viewModel.sendComposer()
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.runCompletionStream == 1)
|
||||
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||
let client = MockSybilClient()
|
||||
@@ -983,3 +784,23 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||
}
|
||||
|
||||
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
|
||||
let targetHeight: CGFloat = 320
|
||||
let baselineAssistantHeight: CGFloat = 28
|
||||
|
||||
#expect(
|
||||
SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: targetHeight,
|
||||
baselineAssistantHeight: baselineAssistantHeight,
|
||||
currentAssistantHeight: baselineAssistantHeight + 120
|
||||
) == 200
|
||||
)
|
||||
#expect(
|
||||
SybilTranscriptTailSpacer.placeholderHeight(
|
||||
targetHeight: targetHeight,
|
||||
baselineAssistantHeight: baselineAssistantHeight,
|
||||
currentAssistantHeight: baselineAssistantHeight + 500
|
||||
) == SybilTranscriptTailSpacer.minimumHeight
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
require "dotenv"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||
|
||||
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||
@@ -1,177 +0,0 @@
|
||||
require "dotenv"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
require "yaml"
|
||||
|
||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||
IOS_ROOT = File.expand_path("..", __dir__)
|
||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||
SCHEME = "Sybil"
|
||||
TARGET = "SybilApp"
|
||||
|
||||
def present?(value)
|
||||
!value.to_s.strip.empty?
|
||||
end
|
||||
|
||||
def capture(command)
|
||||
stdout, stderr, status = Open3.capture3(command)
|
||||
return stdout.strip if status.success?
|
||||
|
||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||
end
|
||||
|
||||
def app_project_settings
|
||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||
end
|
||||
|
||||
def local_marketing_version
|
||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||
end
|
||||
|
||||
def local_build_number
|
||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||
end
|
||||
|
||||
def normalize_version_tag(tag)
|
||||
version = tag.to_s.strip.sub(/\Av/, "")
|
||||
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||
end
|
||||
version
|
||||
end
|
||||
|
||||
def release_version
|
||||
tag = ENV["SYBIL_VERSION_TAG"]
|
||||
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||
normalize_version_tag(tag)
|
||||
end
|
||||
|
||||
def xcode_build_setting(key, value)
|
||||
"#{key}=#{value.to_s.shellescape}"
|
||||
end
|
||||
|
||||
def app_store_connect_key_options
|
||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||
return nil unless present?(key_id) && present?(issuer_id)
|
||||
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||
if present?(key_path)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_filepath: key_path
|
||||
}
|
||||
elsif present?(key_content)
|
||||
{
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_content: key_content,
|
||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||
lane :version do
|
||||
UI.message("Git tag version: #{release_version}")
|
||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||
UI.message("Checked-in build number: #{local_build_number}")
|
||||
end
|
||||
|
||||
desc "Build Sybil and upload it to TestFlight"
|
||||
lane :beta do
|
||||
version = release_version
|
||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||
api_key = nil
|
||||
|
||||
if app_store_connect_key_options
|
||||
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||
end
|
||||
|
||||
unless present?(build_number)
|
||||
build_number = (local_build_number + 1).to_s
|
||||
|
||||
if api_key
|
||||
begin
|
||||
latest = latest_testflight_build_number(
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
version: version,
|
||||
api_key: api_key,
|
||||
initial_build_number: local_build_number
|
||||
).to_i
|
||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||
rescue StandardError => e
|
||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||
|
||||
xcode_args = [
|
||||
"-allowProvisioningUpdates",
|
||||
xcode_build_setting("MARKETING_VERSION", version),
|
||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||
].join(" ")
|
||||
|
||||
ipa_path = build_app(
|
||||
project: PROJECT_FILE,
|
||||
scheme: SCHEME,
|
||||
clean: true,
|
||||
sdk: "iphoneos",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||
xcargs: xcode_args,
|
||||
export_xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
method: "app-store-connect",
|
||||
destination: "export",
|
||||
signingStyle: "automatic",
|
||||
teamID: TEAM_ID,
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
uploadSymbols: true,
|
||||
stripSwiftSymbols: true
|
||||
}
|
||||
)
|
||||
|
||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||
|
||||
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||
|
||||
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||
sh([
|
||||
"xcrun altool",
|
||||
"--upload-package #{ipa_path.shellescape}",
|
||||
"--platform ios",
|
||||
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||
"--bundle-version #{build_number.shellescape}",
|
||||
"--bundle-short-version-string #{version.shellescape}",
|
||||
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||
"--show-progress"
|
||||
].join(" "))
|
||||
end
|
||||
end
|
||||
@@ -1,40 +0,0 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios version
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios version
|
||||
```
|
||||
|
||||
Show the version Fastlane will stamp into the next TestFlight archive
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Build Sybil and upload it to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
12
ios/justfile
12
ios/justfile
@@ -5,10 +5,8 @@ derived_data := "build/DerivedData"
|
||||
default:
|
||||
@just build
|
||||
|
||||
generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
build:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
@@ -18,15 +16,13 @@ build: generate
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
|
||||
run: generate
|
||||
run:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||
xcrun simctl launch booted net.buzzert.sybil2
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
screenshot path="build/sybil-screenshot.png":
|
||||
mkdir -p "$(dirname '{{path}}')"
|
||||
xcrun simctl io booted screenshot '{{path}}'
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||
@@ -1,44 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'folder',
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"chatId" TEXT,
|
||||
"searchId" TEXT,
|
||||
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Add normalized search query lookup key for cache/reuse behavior.
|
||||
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
|
||||
|
||||
UPDATE "Search"
|
||||
SET "queryNormalized" = lower(trim("query"))
|
||||
WHERE "query" IS NOT NULL AND trim("query") != '';
|
||||
|
||||
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
|
||||
@@ -27,11 +27,6 @@ enum SearchSource {
|
||||
exa
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
starred
|
||||
folder
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -42,7 +37,6 @@ model User {
|
||||
|
||||
chats Chat[]
|
||||
searches Search[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
@@ -57,15 +51,11 @@ model Chat {
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -121,7 +111,6 @@ model Search {
|
||||
|
||||
title String?
|
||||
query String?
|
||||
queryNormalized String?
|
||||
|
||||
source SearchSource @default(exa)
|
||||
|
||||
@@ -140,10 +129,8 @@ model Search {
|
||||
userId String?
|
||||
|
||||
results SearchResult[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([queryNormalized, updatedAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -169,40 +156,3 @@ model SearchResult {
|
||||
|
||||
@@index([searchId, rank])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
kind ProjectKind @default(folder)
|
||||
title String
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
|
||||
items ProjectItem[]
|
||||
|
||||
@@index([kind])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ProjectItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
|
||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
chatId String?
|
||||
|
||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
||||
searchId String?
|
||||
|
||||
@@unique([projectId, chatId])
|
||||
@@unique([projectId, searchId])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { warmModelCatalog } from "./llm/model-catalog.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -21,7 +21,6 @@ const app = Fastify({
|
||||
|
||||
await ensureDatabaseReady(app.log);
|
||||
await warmModelCatalog(app.log);
|
||||
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -81,10 +80,6 @@ app.setErrorHandler((err, req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
stopModelCatalogRefreshLoop();
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
|
||||
@@ -9,11 +9,7 @@ import { z } from "zod";
|
||||
import { env } from "../env.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import {
|
||||
buildOpenAIConversationMessage,
|
||||
buildOpenAIResponsesInputMessage,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
} from "./message-content.js";
|
||||
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
@@ -192,43 +188,7 @@ const CHAT_TOOLS: any[] = [
|
||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_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) => {
|
||||
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
return {
|
||||
type: "function",
|
||||
@@ -238,7 +198,6 @@ function toResponsesChatTools(tools: any[]) {
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -280,8 +239,6 @@ type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||
@@ -292,17 +249,15 @@ type ToolAwareCompletionParams = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
|
||||
|
||||
export type ToolExecutionEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: ToolExecutionStatus;
|
||||
status: "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -330,13 +285,10 @@ function toSingleLine(value: string, maxLength = 220) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
||||
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
||||
if (name === "web_search") {
|
||||
const query = typeof args.query === "string" ? args.query.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
||||
}
|
||||
@@ -345,9 +297,6 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: T
|
||||
|
||||
if (name === "fetch_url") {
|
||||
const url = typeof args.url === "string" ? args.url.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
||||
}
|
||||
@@ -356,9 +305,6 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: T
|
||||
|
||||
if (name === "codex_exec") {
|
||||
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
||||
}
|
||||
@@ -367,9 +313,6 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: T
|
||||
|
||||
if (name === "shell_exec") {
|
||||
const command = typeof args.command === "string" ? args.command.trim() : "";
|
||||
if (status === "initiated") {
|
||||
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
|
||||
}
|
||||
if (status === "completed") {
|
||||
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
||||
}
|
||||
@@ -378,9 +321,6 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: T
|
||||
: `Devbox shell command failed.${errSuffix}`;
|
||||
}
|
||||
|
||||
if (status === "initiated") {
|
||||
return `Running tool '${name}'.`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return `Ran tool '${name}'.`;
|
||||
}
|
||||
@@ -439,38 +379,20 @@ function extractHtmlTitle(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return (
|
||||
"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 normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
}
|
||||
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
}
|
||||
|
||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
@@ -986,55 +908,17 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
||||
}));
|
||||
}
|
||||
|
||||
type PreparedToolCallExecution = {
|
||||
startedAtMs: number;
|
||||
startedAt: string;
|
||||
parsedArgs: Record<string, unknown>;
|
||||
eventArgs: Record<string, unknown>;
|
||||
parseError?: unknown;
|
||||
};
|
||||
|
||||
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||
const startedAtMs = Date.now();
|
||||
const startedAt = new Date(startedAtMs).toISOString();
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
|
||||
let parseError: unknown;
|
||||
try {
|
||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||
} catch (err) {
|
||||
parseError = err;
|
||||
}
|
||||
|
||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||
return {
|
||||
event: {
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
status: "initiated",
|
||||
summary: buildToolSummary(call.name, eventArgs, "initiated"),
|
||||
args: eventArgs,
|
||||
startedAt,
|
||||
},
|
||||
execution: {
|
||||
startedAtMs,
|
||||
startedAt,
|
||||
parsedArgs,
|
||||
eventArgs,
|
||||
parseError,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeToolCallAndBuildEvent(
|
||||
call: NormalizedToolCall,
|
||||
execution: PreparedToolCallExecution,
|
||||
params: ToolAwareCompletionParams
|
||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||
const startedAtMs = Date.now();
|
||||
const startedAt = new Date(startedAtMs).toISOString();
|
||||
let toolResult: ToolRunOutcome;
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
try {
|
||||
if (execution.parseError) throw execution.parseError;
|
||||
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||
toolResult = await executeTool(call.name, parsedArgs);
|
||||
} catch (err: any) {
|
||||
toolResult = {
|
||||
ok: false,
|
||||
@@ -1051,15 +935,16 @@ async function executeToolCallAndBuildEvent(
|
||||
: undefined;
|
||||
|
||||
const completedAtMs = Date.now();
|
||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||
const event: ToolExecutionEvent = {
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
status,
|
||||
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||
args: execution.eventArgs,
|
||||
startedAt: execution.startedAt,
|
||||
summary: buildToolSummary(call.name, eventArgs, status, error),
|
||||
args: eventArgs,
|
||||
startedAt,
|
||||
completedAt: new Date(completedAtMs).toISOString(),
|
||||
durationMs: completedAtMs - execution.startedAtMs,
|
||||
durationMs: completedAtMs - startedAtMs,
|
||||
error,
|
||||
resultPreview: buildResultPreview(toolResult),
|
||||
};
|
||||
@@ -1072,8 +957,7 @@ async function executeToolCallAndBuildEvent(
|
||||
}
|
||||
|
||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1087,7 +971,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1122,8 +1006,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
@@ -1143,8 +1026,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
}
|
||||
|
||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1158,7 +1040,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tools: CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
@@ -1210,8 +1092,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
@@ -1233,7 +1114,7 @@ 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),
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
@@ -1253,8 +1134,7 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
|
||||
export async function* runToolAwareOpenAIChatStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1268,7 +1148,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1355,9 +1235,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
@@ -1382,8 +1260,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
export async function* runToolAwareChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1397,7 +1274,7 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tools: CHAT_TOOLS,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -1494,9 +1371,7 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
@@ -1528,7 +1403,7 @@ export async function* runPlainChatCompletionsStream(
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
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) {
|
||||
return value.replace(/"/g, """);
|
||||
}
|
||||
@@ -212,18 +198,11 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||
return {
|
||||
role: "system",
|
||||
content: buildSystemPromptAugmentation(userLocation),
|
||||
};
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapsho
|
||||
|
||||
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelCatalog: ModelCatalogSnapshot = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
@@ -21,8 +20,6 @@ const modelCatalog: ModelCatalogSnapshot = {
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
|
||||
let catalogRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
function getCatalogProviders(): Provider[] {
|
||||
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||
}
|
||||
@@ -89,42 +86,17 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
||||
} catch (err: any) {
|
||||
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] = {
|
||||
models: previous?.models.length ? previous.models : fallbackModels,
|
||||
loadedAt: previous?.loadedAt ?? null,
|
||||
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
|
||||
loadedAt: new Date().toISOString(),
|
||||
error: message,
|
||||
};
|
||||
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) {
|
||||
await refreshModelCatalog(logger);
|
||||
}
|
||||
|
||||
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
||||
const timer = setInterval(() => {
|
||||
void refreshModelCatalog(logger);
|
||||
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
|
||||
timer.unref?.();
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
|
||||
}
|
||||
|
||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||
@@ -47,16 +47,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
let usage: MultiplexResponse["usage"] | undefined;
|
||||
let raw: unknown;
|
||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
|
||||
if (req.provider === "openai" && enabledTools.length > 0) {
|
||||
if (req.provider === "openai") {
|
||||
const client = openaiClient();
|
||||
const r = await runToolAwareOpenAIChat({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -69,14 +66,12 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
||||
} else if (req.provider === "xai") {
|
||||
const client = xaiClient();
|
||||
const r = await runToolAwareChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -89,13 +84,12 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
} else if (req.provider === "hermes-agent") {
|
||||
const client = hermesAgentClient();
|
||||
const r = await runPlainChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -110,7 +104,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const r = await client.messages.create({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
normalizeEnabledChatTools,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
@@ -77,15 +76,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
const streamEvents =
|
||||
req.provider === "openai" && enabledTools.length > 0
|
||||
req.provider === "openai"
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -94,12 +90,11 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||
: req.provider === "hermes-agent"
|
||||
? runPlainChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -112,8 +107,6 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -130,7 +123,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
}
|
||||
|
||||
if (ev.type === "tool_call") {
|
||||
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||
if (shouldPersist && chatId) {
|
||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
@@ -153,7 +146,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const stream = await client.messages.create({
|
||||
|
||||
@@ -36,9 +36,6 @@ export type MultiplexRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
@@ -8,17 +8,13 @@ import { env } from "./env.js";
|
||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||
import { runMultiplex } from "./llm/multiplexer.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 { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
import { exaClient } from "./search/exa.js";
|
||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.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 = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
@@ -51,43 +47,6 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||
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[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
@@ -172,9 +131,6 @@ const CompletionStreamBody = z
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
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(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
@@ -199,41 +155,6 @@ 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({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
@@ -400,153 +321,11 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
const starredProjectItemsSelect = {
|
||||
where: { projectId: STARRED_PROJECT_ID },
|
||||
select: { createdAt: true },
|
||||
take: 1,
|
||||
} as const;
|
||||
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
const searchSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
query: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
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();
|
||||
}
|
||||
|
||||
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
|
||||
const star = item.projectItems?.[0];
|
||||
return {
|
||||
starred: Boolean(star),
|
||||
starredAt: star?.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeChatLike<T extends Record<string, any>>(chat: T) {
|
||||
const { projectItems: _projectItems, ...rest } = chat;
|
||||
return {
|
||||
...serializeProviderFields(rest),
|
||||
...serializeStarFields(chat),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
||||
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
|
||||
return {
|
||||
...rest,
|
||||
...serializeStarFields(search),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureStarredProject() {
|
||||
await prisma.project.upsert({
|
||||
where: { id: STARRED_PROJECT_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: STARRED_PROJECT_ID,
|
||||
kind: "starred" as any,
|
||||
title: "Starred",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getChatSummary(chatId: string) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return chat ? serializeChatLike(chat) : null;
|
||||
}
|
||||
|
||||
async function getSearchSummary(searchId: string) {
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
select: searchSummarySelect,
|
||||
});
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, chatId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
|
||||
}
|
||||
|
||||
return getChatSummary(chatId);
|
||||
}
|
||||
|
||||
async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, searchId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
|
||||
}
|
||||
|
||||
return getSearchSummary(searchId);
|
||||
}
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return [
|
||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
||||
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
||||
].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||
reply.raw.write(`event: ${event.event}\n`);
|
||||
@@ -730,7 +509,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
@@ -751,15 +529,12 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) {
|
||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||
} else {
|
||||
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
||||
stream.complete({ event: "done", data: { search } });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
@@ -768,7 +543,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
error: message,
|
||||
@@ -796,11 +570,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { providers: getModelCatalogSnapshot() };
|
||||
});
|
||||
|
||||
app.get("/v1/chat-tools", async (req) => {
|
||||
requireAdmin(req);
|
||||
return { tools: getAvailableChatTools() };
|
||||
});
|
||||
|
||||
app.get("/v1/active-runs", async (req) => {
|
||||
requireAdmin(req);
|
||||
return {
|
||||
@@ -809,19 +578,23 @@ 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) => {
|
||||
requireAdmin(req);
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats", async (req) => {
|
||||
@@ -831,8 +604,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
title: z.string().optional(),
|
||||
provider: ProviderSchema.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(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -861,8 +632,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: body.model,
|
||||
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||
lastUsedModel: body.model,
|
||||
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
||||
enabledTools: body.enabledTools as any,
|
||||
messages: body.messages?.length
|
||||
? {
|
||||
create: body.messages.map((message) => ({
|
||||
@@ -874,49 +643,49 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
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 Body = z.object({ title: z.string().trim().min(1) });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
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({
|
||||
where: { id: chatId },
|
||||
data: data as any,
|
||||
data: { title: body.title },
|
||||
});
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
const chat = await getChatSummary(chatId);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const chat = await setChatStarred(chatId, body.starred);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/title/suggest", async (req) => {
|
||||
@@ -929,24 +698,40 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const existing = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
const chat = await prisma.chat.update({
|
||||
where: { id: body.chatId },
|
||||
data: { title },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.delete("/v1/chats/:chatId", async (req) => {
|
||||
@@ -971,69 +756,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const searches = await prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: searchSummarySelect,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
});
|
||||
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
||||
return { searches };
|
||||
});
|
||||
|
||||
app.post("/v1/searches", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Body = z.object({
|
||||
title: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
reuseByQuery: z.boolean().optional(),
|
||||
});
|
||||
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
|
||||
const body = Body.parse(req.body ?? {});
|
||||
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
||||
const query = body.query?.trim() || null;
|
||||
const queryNormalized = normalizeSearchQuery(query);
|
||||
|
||||
if (body.reuseByQuery && queryNormalized) {
|
||||
const existing = await prisma.search.findFirst({
|
||||
where: { queryNormalized },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: {
|
||||
...searchSummarySelect,
|
||||
answerText: true,
|
||||
_count: { select: { results: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const { _count, answerText: _answerText, ...search } = existing;
|
||||
return {
|
||||
search: serializeSearchLike(search),
|
||||
reused: true,
|
||||
cacheHit: isFreshSearchCacheHit({
|
||||
updatedAt: existing.updatedAt,
|
||||
resultCount: _count.results,
|
||||
answerText: existing.answerText,
|
||||
isActive: activeSearchStreams.has(existing.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const search = await prisma.search.create({
|
||||
data: {
|
||||
title: title || null,
|
||||
query,
|
||||
queryNormalized,
|
||||
},
|
||||
select: searchSummarySelect,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
});
|
||||
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
|
||||
});
|
||||
|
||||
app.patch("/v1/searches/:searchId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const search = await setSearchStarred(searchId, body.starred);
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search };
|
||||
});
|
||||
|
||||
@@ -1060,13 +800,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search: serializeSearchLike(search) };
|
||||
return { search };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||
@@ -1102,10 +839,19 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||
@@ -1166,7 +912,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
queryNormalized: normalizeSearchQuery(query),
|
||||
title: normalizedTitle,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
@@ -1191,13 +936,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search: serializeSearchLike(search) };
|
||||
return { search };
|
||||
} catch (err: any) {
|
||||
await prisma.search.update({
|
||||
where: { id: searchId },
|
||||
@@ -1252,14 +994,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||
@@ -1309,16 +1047,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
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(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
const body = parsed.data;
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -1331,7 +1066,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||
const result = await runMultiplex(body);
|
||||
|
||||
return {
|
||||
chatId: body.chatId ?? null,
|
||||
@@ -1345,7 +1080,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
const body = parsed.data;
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -1362,14 +1097,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
const stream = startActiveChatStream(body.chatId, body);
|
||||
return streamActiveRun(req, reply, stream);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||
for await (const ev of runMultiplexStream(body)) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function normalizeSearchQuery(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
||||
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
||||
}
|
||||
|
||||
export function isFreshSearchCacheHit(
|
||||
candidate: {
|
||||
updatedAt: Date | string;
|
||||
resultCount: number;
|
||||
answerText?: string | null;
|
||||
isActive?: boolean;
|
||||
},
|
||||
now = new Date(),
|
||||
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
||||
) {
|
||||
if (candidate.isActive) return false;
|
||||
if (!hasReusableSearchPayload(candidate)) return false;
|
||||
|
||||
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
||||
if (!Number.isFinite(updatedAtMs)) return false;
|
||||
|
||||
return now.getTime() - updatedAtMs <= ttlMs;
|
||||
}
|
||||
@@ -140,69 +140,3 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||
});
|
||||
|
||||
test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
function: {
|
||||
name: "unknown_tool",
|
||||
arguments: "{\"query\":\"current weather\"}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{ choices: [{ delta: { content: "Done" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
]);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareChatCompletionsStream({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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\./);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
||||
|
||||
test("normalizeSearchQuery trims and lowercases query text", () => {
|
||||
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
||||
assert.equal(normalizeSearchQuery(" "), null);
|
||||
assert.equal(normalizeSearchQuery(null), null);
|
||||
});
|
||||
|
||||
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
||||
const now = new Date("2026-05-31T12:00:00.000Z");
|
||||
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
||||
false
|
||||
);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
SearchStreamHandlers,
|
||||
SearchSummary,
|
||||
SessionStatus,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -42,11 +41,6 @@ export class SybilApiClient {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
async listWorkspaceItems() {
|
||||
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async createChat(title?: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||
method: "POST",
|
||||
@@ -60,22 +54,6 @@ export class SybilApiClient {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatTitle(chatId: string, title: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||
method: "PATCH",
|
||||
body: { title },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -106,14 +84,6 @@ export class SybilApiClient {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async deleteSearch(searchId: string) {
|
||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -124,7 +94,6 @@ export class SybilApiClient {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
319
tui/src/index.ts
319
tui/src/index.ts
@@ -11,7 +11,6 @@ import type {
|
||||
SearchDetail,
|
||||
SearchSummary,
|
||||
ToolCallEvent,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
@@ -20,8 +19,6 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -32,7 +29,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
status?: "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -96,67 +93,33 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...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 {
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
lastUsedModel: null,
|
||||
};
|
||||
});
|
||||
})),
|
||||
];
|
||||
|
||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}
|
||||
|
||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
@@ -171,7 +134,13 @@ function isToolCallLogMessage(message: Message) {
|
||||
}
|
||||
|
||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
const metadata: ToolLogMetadata = {
|
||||
return {
|
||||
id: `temp-tool-${event.toolCallId}`,
|
||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata: {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
@@ -179,37 +148,12 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
summary: event.summary,
|
||||
args: event.args,
|
||||
startedAt: event.startedAt,
|
||||
completedAt: event.completedAt,
|
||||
durationMs: event.durationMs,
|
||||
error: event.error ?? null,
|
||||
resultPreview: event.resultPreview ?? null,
|
||||
} satisfies ToolLogMetadata,
|
||||
};
|
||||
|
||||
if (event.completedAt) metadata.completedAt = event.completedAt;
|
||||
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
|
||||
|
||||
return {
|
||||
id: `temp-tool-${event.toolCallId}`,
|
||||
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
|
||||
const toolMessage = buildOptimisticToolMessage(event);
|
||||
const existingIndex = messages.findIndex(
|
||||
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
|
||||
}
|
||||
|
||||
const assistantIndex = messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||
}
|
||||
|
||||
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||
@@ -251,7 +195,6 @@ async function main() {
|
||||
let authMode: "open" | "token" | null = null;
|
||||
let chats: ChatSummary[] = [];
|
||||
let searches: SearchSummary[] = [];
|
||||
let workspaceItems: WorkspaceItem[] = [];
|
||||
let selectedItem: SidebarSelection | null = null;
|
||||
let selectedChat: ChatDetail | null = null;
|
||||
let selectedSearch: SearchDetail | null = null;
|
||||
@@ -279,7 +222,6 @@ async function main() {
|
||||
let renderedSidebarItems: SidebarItem[] = [];
|
||||
let renderedSidebarLines: string[] = [];
|
||||
let suppressedSidebarSelectEvents = 0;
|
||||
let isRenamePromptOpen = false;
|
||||
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
@@ -387,26 +329,6 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const renamePrompt = (blessed as any).prompt({
|
||||
parent: screen,
|
||||
label: " Rename chat ",
|
||||
border: "line",
|
||||
tags: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
mouse: true,
|
||||
top: "center",
|
||||
left: "center",
|
||||
width: "50%",
|
||||
height: "shrink",
|
||||
hidden: true,
|
||||
style: {
|
||||
border: { fg: "cyan" },
|
||||
label: { fg: "cyan" },
|
||||
fg: "white",
|
||||
},
|
||||
});
|
||||
|
||||
const focusables = [sidebar, transcript, composer] as const;
|
||||
|
||||
function getTranscriptViewportHeight() {
|
||||
@@ -455,7 +377,7 @@ async function main() {
|
||||
}
|
||||
|
||||
function getSidebarItems() {
|
||||
return buildSidebarItems(workspaceItems);
|
||||
return buildSidebarItems(chats, searches);
|
||||
}
|
||||
|
||||
function getSelectedChatSummary() {
|
||||
@@ -546,13 +468,12 @@ async function main() {
|
||||
? ["No chats/searches yet. Press n or /. "]
|
||||
: items.map((item) => {
|
||||
const kind = item.kind === "chat" ? "C" : "S";
|
||||
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
||||
const title = truncate(item.title, 36);
|
||||
const initiatedLabel =
|
||||
item.kind === "chat" && item.initiatedModel
|
||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||
: "";
|
||||
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
});
|
||||
|
||||
const linesChanged =
|
||||
@@ -621,12 +542,7 @@ async function main() {
|
||||
for (const message of messages) {
|
||||
const toolMeta = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolMeta) {
|
||||
const prefix =
|
||||
toolMeta.status === "failed"
|
||||
? "{red-fg}[tool failed]{/red-fg}"
|
||||
: toolMeta.status === "initiated"
|
||||
? "{yellow-fg}[tool running]{/yellow-fg}"
|
||||
: "{cyan-fg}[tool]{/cyan-fg}";
|
||||
const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}";
|
||||
const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
|
||||
parts.push(`${prefix} ${escapeTags(summary)}`);
|
||||
continue;
|
||||
@@ -732,7 +648,7 @@ async function main() {
|
||||
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||
|
||||
let controls =
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
|
||||
if (!isSearchMode) {
|
||||
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
|
||||
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||
@@ -785,7 +701,6 @@ async function main() {
|
||||
function resetWorkspaceState() {
|
||||
chats = [];
|
||||
searches = [];
|
||||
workspaceItems = [];
|
||||
selectedItem = null;
|
||||
selectedChat = null;
|
||||
selectedSearch = null;
|
||||
@@ -852,13 +767,11 @@ async function main() {
|
||||
updateUI();
|
||||
|
||||
try {
|
||||
const nextWorkspaceItems = await api.listWorkspaceItems();
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
workspaceItems = nextWorkspaceItems;
|
||||
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
|
||||
chats = nextChats;
|
||||
searches = nextSearches;
|
||||
|
||||
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||
selectedItem = options.preferredSelection;
|
||||
draftKind = null;
|
||||
@@ -894,27 +807,6 @@ async function main() {
|
||||
composer.readInput();
|
||||
}
|
||||
|
||||
function shouldIgnoreGlobalShortcut() {
|
||||
return isRenamePromptOpen || isTextInputFocused(screen, composer);
|
||||
}
|
||||
|
||||
function promptForChatTitle(currentTitle: string) {
|
||||
isRenamePromptOpen = true;
|
||||
updateUI();
|
||||
return new Promise<string | null>((resolve) => {
|
||||
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
|
||||
isRenamePromptOpen = false;
|
||||
renamePrompt.hide();
|
||||
screen.render();
|
||||
if (err || value === null || value === undefined) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cycleFocus(step: 1 | -1) {
|
||||
const focused = screen.focused;
|
||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||
@@ -983,20 +875,9 @@ async function main() {
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||
}
|
||||
updateUI();
|
||||
} catch {
|
||||
@@ -1039,7 +920,6 @@ async function main() {
|
||||
chatId = chat.id;
|
||||
draftKind = null;
|
||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||
selectedItem = { kind: "chat", id: chat.id };
|
||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||
selectedChat = {
|
||||
@@ -1047,8 +927,6 @@ async function main() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -1107,7 +985,29 @@ async function main() {
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
if (!pendingChatState) return;
|
||||
pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
|
||||
const alreadyPresent = pendingChatState.messages.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
);
|
||||
if (alreadyPresent) return;
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = pendingChatState.messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
|
||||
if (assistantIndex < 0) {
|
||||
pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) };
|
||||
} else {
|
||||
pendingChatState = {
|
||||
...pendingChatState,
|
||||
messages: [
|
||||
...pendingChatState.messages.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...pendingChatState.messages.slice(assistantIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
@@ -1185,7 +1085,6 @@ async function main() {
|
||||
draftKind = null;
|
||||
selectedItem = { kind: "search", id: searchId };
|
||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||
selectedChat = null;
|
||||
forceScrollToBottom = true;
|
||||
updateUI();
|
||||
@@ -1203,8 +1102,6 @@ async function main() {
|
||||
query,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -1367,88 +1264,6 @@ async function main() {
|
||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||
}
|
||||
|
||||
async function handleRenameSelection() {
|
||||
if (!selectedItem || selectedItem.kind !== "chat") return;
|
||||
|
||||
const chatId = selectedItem.id;
|
||||
const summary = chats.find((chat) => chat.id === chatId);
|
||||
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
|
||||
const value = await promptForChatTitle(currentTitle);
|
||||
const title = value?.trim();
|
||||
if (!title) {
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const updated = await api.updateChatTitle(chatId, title);
|
||||
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
|
||||
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
|
||||
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedSearch?.id === updated.id) {
|
||||
selectedSearch = {
|
||||
...selectedSearch,
|
||||
title: updated.title,
|
||||
query: updated.query,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||
@@ -1534,18 +1349,18 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["q"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
screen.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
screen.key(["tab"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
cycleFocus(1);
|
||||
});
|
||||
|
||||
screen.key(["S-tab", "backtab"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
cycleFocus(-1);
|
||||
});
|
||||
|
||||
@@ -1562,50 +1377,36 @@ async function main() {
|
||||
});
|
||||
|
||||
screen.key(["n"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
handleCreateChat();
|
||||
});
|
||||
|
||||
screen.key(["/"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
handleCreateSearch();
|
||||
});
|
||||
|
||||
screen.key(["d"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
void runAction(async () => {
|
||||
await handleDeleteSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["s"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleToggleStarSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["p"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleProvider();
|
||||
});
|
||||
|
||||
screen.key(["m"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
cycleModel();
|
||||
});
|
||||
|
||||
screen.key(["r"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleRenameSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["C-r"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (isTextInputFocused(screen, composer)) return;
|
||||
void runAction(async () => {
|
||||
await refreshCollections({ loadSelection: true });
|
||||
await refreshModels();
|
||||
|
||||
@@ -15,8 +15,6 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -29,20 +27,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -55,12 +41,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "initiated" | "completed" | "failed";
|
||||
status: "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -70,8 +56,6 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -101,8 +85,6 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Sybil chat and search workspace" />
|
||||
<meta name="application-name" content="Sybil" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
||||
<title>Sybil</title>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
@@ -1,32 +1,9 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Sybil",
|
||||
"short_name": "Sybil",
|
||||
"description": "Sybil chat and search workspace",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"background_color": "#0b0718",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a"
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
975
web/src/App.tsx
975
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||
return (
|
||||
<div className="app-grid-surface app-safe-pad flex h-full items-center justify-center">
|
||||
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||
<div className="mb-6">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
|
||||
@@ -14,7 +14,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
status?: "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -71,17 +71,9 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||
|
||||
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
|
||||
if (metadata.status === "failed") return "failed";
|
||||
if (metadata.status === "initiated") return "initiated";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
||||
return [
|
||||
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||
isFailed ? "Failed" : "Completed",
|
||||
formatDuration(metadata.durationMs),
|
||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||
]
|
||||
@@ -101,12 +93,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const toolState = getToolVisualState(toolLogMetadata);
|
||||
const isFailed = toolState === "failed";
|
||||
const isInitiated = toolState === "initiated";
|
||||
const isFailed = toolLogMetadata.status === "failed";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
@@ -114,8 +104,6 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||
)}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
@@ -123,11 +111,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -137,7 +121,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
{toolSummary}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--background: 235 45% 4%;
|
||||
--foreground: 258 36% 96%;
|
||||
--muted: 246 30% 13%;
|
||||
@@ -44,15 +40,6 @@ html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -62,8 +49,6 @@ body {
|
||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -93,44 +78,6 @@ textarea {
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.app-safe-frame {
|
||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left);
|
||||
}
|
||||
|
||||
.app-safe-pad {
|
||||
padding:
|
||||
max(1rem, var(--safe-area-top))
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(1rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(0.75rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
padding:
|
||||
max(1.5rem, var(--safe-area-top))
|
||||
max(1.5rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(1.5rem, var(--safe-area-left));
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||
|
||||
@@ -3,14 +3,10 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
};
|
||||
|
||||
export type SearchSummary = {
|
||||
@@ -19,20 +15,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -45,12 +29,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "initiated" | "completed" | "failed";
|
||||
status: "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -60,14 +44,10 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -93,8 +73,6 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
@@ -161,11 +139,6 @@ export type ModelCatalogResponse = {
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ActiveRunsResponse = {
|
||||
chats: string[];
|
||||
searches: string[];
|
||||
@@ -191,23 +164,9 @@ type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
type CreateSearchRequest = {
|
||||
title?: string;
|
||||
query?: string;
|
||||
reuseByQuery?: boolean;
|
||||
};
|
||||
|
||||
type CreateSearchResponse = {
|
||||
search: SearchSummary;
|
||||
reused: boolean;
|
||||
cacheHit: boolean;
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||
@@ -255,11 +214,6 @@ export async function listChats() {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function listWorkspaceItems() {
|
||||
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||
}
|
||||
@@ -268,11 +222,6 @@ export async function listModels() {
|
||||
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() {
|
||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||
}
|
||||
@@ -299,25 +248,6 @@ export async function updateChatTitle(chatId: string, title: string) {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatSettings(
|
||||
chatId: string,
|
||||
body: { title?: string; 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 }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -335,35 +265,19 @@ export async function listSearches() {
|
||||
return data.searches;
|
||||
}
|
||||
|
||||
async function postSearch(body?: CreateSearchRequest) {
|
||||
return api<CreateSearchResponse>("/v1/searches", {
|
||||
export async function createSearch(body?: { title?: string; query?: string }) {
|
||||
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSearch(body?: CreateSearchRequest) {
|
||||
const data = await postSearch(body);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
|
||||
return postSearch({ ...body, reuseByQuery: true });
|
||||
}
|
||||
|
||||
export async function getSearch(searchId: string) {
|
||||
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||
method: "POST",
|
||||
@@ -640,9 +554,6 @@ export async function runCompletion(body: {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
}) {
|
||||
return api<CompletionResponse>("/v1/chat-completions", {
|
||||
method: "POST",
|
||||
@@ -657,9 +568,6 @@ export async function runCompletionStream(
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { render } from "preact";
|
||||
import { RootRouter } from "@/root-router";
|
||||
import { registerServiceWorker } from "@/pwa";
|
||||
import "./index.css";
|
||||
|
||||
registerServiceWorker();
|
||||
|
||||
render(<RootRouter />, document.getElementById("app")!);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
|
||||
function readQueryFromUrl() {
|
||||
@@ -85,16 +85,14 @@ export default function SearchRoutePage() {
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
|
||||
if (!trimmed) {
|
||||
setSearch(null);
|
||||
setError(null);
|
||||
setIsRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++requestCounterRef.current;
|
||||
streamAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
streamAbortRef.current = abortController;
|
||||
let wasInterrupted = false;
|
||||
@@ -108,8 +106,6 @@ export default function SearchRoutePage() {
|
||||
query: trimmed,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -121,11 +117,10 @@ export default function SearchRoutePage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const createdResult = await createReusableSearch({
|
||||
const created = await createSearch({
|
||||
query: trimmed,
|
||||
title: trimmed.slice(0, 80),
|
||||
});
|
||||
const created = createdResult.search;
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
|
||||
setSearch((current) =>
|
||||
@@ -137,19 +132,10 @@ export default function SearchRoutePage() {
|
||||
query: created.query,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
}
|
||||
: current
|
||||
);
|
||||
|
||||
if (createdResult.cacheHit) {
|
||||
const cached = await getSearch(created.id);
|
||||
if (requestId !== requestCounterRef.current) return;
|
||||
setSearch(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
await runSearchStream(
|
||||
created.id,
|
||||
{
|
||||
@@ -262,7 +248,7 @@ export default function SearchRoutePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-search-safe-pad h-full overflow-y-auto">
|
||||
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user