Compare commits
10 Commits
600bc3befc
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7436544a69 | |||
| 95796646b1 | |||
| d7214c88ad | |||
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 |
@@ -42,6 +42,23 @@ Chat upload limits:
|
||||
- `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
|
||||
|
||||
### `GET /v1/active-runs`
|
||||
@@ -77,7 +94,9 @@ Behavior notes:
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
"lastUsedModel": "gpt-4.1-mini"
|
||||
"lastUsedModel": "gpt-4.1-mini",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
},
|
||||
{
|
||||
"type": "search",
|
||||
@@ -111,6 +130,8 @@ 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",
|
||||
@@ -126,13 +147,17 @@ 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: `{ "title": string }`
|
||||
- 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.
|
||||
- Renaming updates the returned chat's `updatedAt`.
|
||||
- `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`
|
||||
@@ -237,6 +262,8 @@ Notes:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -256,6 +283,8 @@ 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`.
|
||||
@@ -270,7 +299,7 @@ Behavior notes:
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
|
||||
- Devbox tool configuration:
|
||||
@@ -285,7 +314,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 persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- `anthropic` currently runs without server-managed tool calls.
|
||||
|
||||
## Searches
|
||||
@@ -390,7 +419,9 @@ Behavior notes:
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null"
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -441,6 +472,8 @@ Behavior notes:
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"additionalSystemPrompt": null,
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"messages": [Message]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -49,6 +49,8 @@ Authentication:
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalSystemPrompt": "optional one-off system prompt",
|
||||
"enabledTools": ["web_search", "fetch_url"],
|
||||
"temperature": 0.2,
|
||||
"maxTokens": 256
|
||||
}
|
||||
@@ -60,6 +62,8 @@ 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:
|
||||
@@ -87,6 +91,8 @@ 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
|
||||
@@ -111,6 +117,19 @@ 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",
|
||||
@@ -121,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
"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
|
||||
@@ -152,6 +172,7 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
|
||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
@@ -174,7 +195,8 @@ Backend database remains source of truth.
|
||||
|
||||
For persisted streams:
|
||||
- Client may optimistically render accumulated `delta` text.
|
||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
- 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.
|
||||
|
||||
On successful persisted completion:
|
||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||
|
||||
20
ios/.env.example
Normal file
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
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,2 +1,11 @@
|
||||
*.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.9
|
||||
CURRENT_PROJECT_VERSION: 10
|
||||
MARKETING_VERSION: "1.10"
|
||||
CURRENT_PROJECT_VERSION: 11
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
3
ios/Gemfile
Normal file
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.227"
|
||||
@@ -661,6 +661,7 @@ struct CompletionStreamRequest: Codable, Sendable {
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
|
||||
@@ -7,39 +7,107 @@ struct SybilChatTranscriptView: View {
|
||||
var isSending: Bool
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
messages.contains { message in
|
||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||
private var renderItems: [TranscriptRenderItem] {
|
||||
buildTranscriptRenderItems(from: messages)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
ForEach(messages.reversed()) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.top, 24)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(groupID: id, messages: messages)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 18 + bottomContentInset)
|
||||
.id(bottomAnchorID)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + bottomContentInset)
|
||||
.padding(.bottom, 18 + topContentInset)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
}
|
||||
.onChange(of: bottomPinRequestID) { _, _ in
|
||||
scrollToBottom(with: proxy, animated: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||
let action = {
|
||||
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.18), action)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TranscriptRenderItem: Identifiable {
|
||||
case message(Message)
|
||||
case toolGroup(id: String, messages: [Message])
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
return message.id
|
||||
case let .toolGroup(id, _):
|
||||
return "tool-group-\(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
|
||||
var items: [TranscriptRenderItem] = []
|
||||
var toolRun: [Message] = []
|
||||
|
||||
func flushToolRun() {
|
||||
guard !toolRun.isEmpty else { return }
|
||||
if toolRun.count == 1, let message = toolRun.first {
|
||||
items.append(.message(message))
|
||||
} else if let first = toolRun.first {
|
||||
items.append(.toolGroup(id: first.id, messages: toolRun))
|
||||
}
|
||||
toolRun.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.toolCallMetadata != nil {
|
||||
toolRun.append(message)
|
||||
} else {
|
||||
flushToolRun()
|
||||
items.append(.message(message))
|
||||
}
|
||||
}
|
||||
|
||||
flushToolRun()
|
||||
return items
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
@@ -137,10 +205,207 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
var y: CGFloat
|
||||
var scale: CGFloat
|
||||
var opacity: Double
|
||||
var zIndex: Double
|
||||
}
|
||||
|
||||
var groupID: String
|
||||
var messages: [Message]
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isExpanded = false
|
||||
|
||||
private let visibleCollapsedLimit = 4
|
||||
private let cardHeight: CGFloat = 62
|
||||
private let expandedGap: CGFloat = 10
|
||||
private let collapsedStepX: CGFloat = 11
|
||||
private let collapsedStepY: CGFloat = 10
|
||||
private let toggleSize: CGFloat = 32
|
||||
private let toggleGap: CGFloat = 12
|
||||
|
||||
private var animation: Animation? {
|
||||
reduceMotion ? nil : .easeInOut(duration: 0.34)
|
||||
}
|
||||
|
||||
private var visibleCollapsedCount: Int {
|
||||
min(messages.count, visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var hiddenCount: Int {
|
||||
max(0, messages.count - visibleCollapsedLimit)
|
||||
}
|
||||
|
||||
private var containerHeight: CGFloat {
|
||||
if isExpanded {
|
||||
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
|
||||
}
|
||||
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
GeometryReader { geometry in
|
||||
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
|
||||
let toggleX = cardWidth + toggleGap
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
||||
let layout = layout(for: index)
|
||||
let depth = messages.count - index - 1
|
||||
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
||||
|
||||
ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true)
|
||||
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
||||
.scaleEffect(layout.scale, anchor: .topLeading)
|
||||
.opacity(layout.opacity)
|
||||
.offset(x: layout.x, y: layout.y)
|
||||
.zIndex(layout.zIndex)
|
||||
.allowsHitTesting(!isHidden)
|
||||
.accessibilityHidden(isHidden)
|
||||
}
|
||||
|
||||
if !isExpanded && hiddenCount > 0 {
|
||||
Text("+\(hiddenCount)")
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.58))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(animation) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
||||
.frame(width: toggleSize, height: toggleSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
|
||||
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
|
||||
.offset(x: toggleX, y: 8)
|
||||
.zIndex(Double(messages.count + 2))
|
||||
}
|
||||
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
|
||||
.animation(animation, value: isExpanded)
|
||||
}
|
||||
.frame(height: containerHeight)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func layout(for index: Int) -> CardLayout {
|
||||
if isExpanded {
|
||||
return CardLayout(
|
||||
x: 0,
|
||||
y: CGFloat(index) * (cardHeight + expandedGap),
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
zIndex: Double(messages.count - index)
|
||||
)
|
||||
}
|
||||
|
||||
let depth = messages.count - index - 1
|
||||
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
|
||||
let isHidden = depth >= visibleCollapsedLimit
|
||||
return CardLayout(
|
||||
x: CGFloat(visibleDepth) * collapsedStepX,
|
||||
y: CGFloat(visibleDepth) * collapsedStepY,
|
||||
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
|
||||
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
|
||||
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackCard: View {
|
||||
var message: Message
|
||||
var cardHeight: CGFloat
|
||||
var compactLayout: Bool
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var didEnter = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let metadata = message.toolCallMetadata {
|
||||
ToolCallActivityChip(
|
||||
metadata: metadata,
|
||||
fallbackContent: message.content,
|
||||
createdAt: message.createdAt,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: cardHeight, alignment: .top)
|
||||
.scaleEffect(didEnter ? 1 : 1.025, anchor: .topLeading)
|
||||
.offset(y: didEnter ? 0 : -8)
|
||||
.rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||
.opacity(didEnter ? 1 : 0.72)
|
||||
.onAppear {
|
||||
guard !didEnter else { return }
|
||||
if reduceMotion {
|
||||
didEnter = true
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
||||
didEnter = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
var compactLayout: Bool = false
|
||||
|
||||
private var summary: String {
|
||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
@@ -184,11 +449,22 @@ private struct ToolCallActivityChip: View {
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
(metadata.status ?? "").lowercased() == "failed"
|
||||
visualState == .failed
|
||||
}
|
||||
|
||||
private var visualState: VisualState {
|
||||
switch (metadata.status ?? "").lowercased() {
|
||||
case "failed":
|
||||
return .failed
|
||||
case "initiated":
|
||||
return .initiated
|
||||
default:
|
||||
return .completed
|
||||
}
|
||||
}
|
||||
|
||||
private var detailLabel: String {
|
||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||
var pieces: [String] = [stateLabel]
|
||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||
pieces.append("\(durationMs) ms")
|
||||
}
|
||||
@@ -200,14 +476,14 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||
.fill(iconColor.opacity(0.13))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
@@ -216,12 +492,14 @@ private struct ToolCallActivityChip: View {
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(compactLayout ? 1 : nil)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: !compactLayout)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(toolLabel)
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||
.foregroundStyle(iconColor.opacity(0.90))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detailLabel)
|
||||
@@ -236,12 +514,45 @@ private struct ToolCallActivityChip: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||
.fill(backgroundGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,8 +514,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?
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ 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()
|
||||
@@ -186,6 +187,17 @@ 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: [
|
||||
|
||||
@@ -107,6 +107,7 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
var errorMessage: String?
|
||||
|
||||
var composer = ""
|
||||
@@ -1186,7 +1187,7 @@ final class SybilViewModel {
|
||||
break
|
||||
|
||||
case let .toolCall(payload):
|
||||
insertQuickQuestionToolCallMessage(payload)
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -1699,6 +1700,10 @@ final class SybilViewModel {
|
||||
isLoadingSelection = false
|
||||
}
|
||||
|
||||
private func requestChatBottomPin() {
|
||||
chatBottomPinRequestID += 1
|
||||
}
|
||||
|
||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||
isLoadingSelection = true
|
||||
let task = Task { [weak self] in
|
||||
@@ -1752,6 +1757,7 @@ final class SybilViewModel {
|
||||
}
|
||||
selectedChat = chat
|
||||
selectedSearch = nil
|
||||
requestChatBottomPin()
|
||||
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
@@ -1824,6 +1830,7 @@ final class SybilViewModel {
|
||||
} else {
|
||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if chatID == nil {
|
||||
let created = try await client.createChat(title: nil)
|
||||
@@ -1871,6 +1878,7 @@ 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 {
|
||||
@@ -2006,7 +2014,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
case let .toolCall(payload):
|
||||
insertPendingToolCallMessage(payload, chatID: chatID)
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -2222,12 +2230,14 @@ final class SybilViewModel {
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
}
|
||||
|
||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
return
|
||||
}
|
||||
|
||||
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2242,8 +2252,9 @@ final class SybilViewModel {
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2255,8 +2266,8 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
||||
let metadata: JSONValue = .object([
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
|
||||
var metadataObject: [String: JSONValue] = [
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string(payload.toolCallId),
|
||||
"toolName": .string(payload.name),
|
||||
@@ -2264,19 +2275,26 @@ 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: "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: Date(),
|
||||
id: id ?? "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
|
||||
role: .tool,
|
||||
content: summary,
|
||||
name: payload.name,
|
||||
@@ -2284,6 +2302,19 @@ 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
|
||||
|
||||
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
|
||||
@@ -402,6 +402,70 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
)
|
||||
}
|
||||
|
||||
private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message {
|
||||
Message(
|
||||
id: id,
|
||||
createdAt: date,
|
||||
role: .tool,
|
||||
content: summary,
|
||||
name: "web_search",
|
||||
metadata: .object([
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string("call-\(id)"),
|
||||
"toolName": .string("web_search"),
|
||||
"status": .string("completed"),
|
||||
"summary": .string(summary),
|
||||
"durationMs": .number(120)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||
let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A")
|
||||
let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B")
|
||||
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||
|
||||
let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant])
|
||||
|
||||
#expect(items.count == 3)
|
||||
guard case let .message(firstMessage) = items[0] else {
|
||||
Issue.record("Expected the first item to remain a normal message")
|
||||
return
|
||||
}
|
||||
#expect(firstMessage.id == "user-1")
|
||||
|
||||
guard case let .toolGroup(groupID, groupedMessages) = items[1] else {
|
||||
Issue.record("Expected adjacent tool calls to be grouped")
|
||||
return
|
||||
}
|
||||
#expect(groupID == "tool-a")
|
||||
#expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"])
|
||||
|
||||
guard case let .message(lastMessage) = items[2] else {
|
||||
Issue.record("Expected the assistant response to remain a normal message")
|
||||
return
|
||||
}
|
||||
#expect(lastMessage.id == "assistant-1")
|
||||
}
|
||||
|
||||
@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||
let tool = makeToolCallMessage(id: "tool-a", date: date)
|
||||
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
|
||||
|
||||
let items = buildTranscriptRenderItems(from: [user, tool, assistant])
|
||||
|
||||
#expect(items.count == 3)
|
||||
guard case let .message(toolMessage) = items[1] else {
|
||||
Issue.record("Expected a single tool call to use the existing inline chip")
|
||||
return
|
||||
}
|
||||
#expect(toolMessage.id == "tool-a")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -495,6 +559,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -682,6 +747,37 @@ 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()
|
||||
|
||||
9
ios/fastlane/Appfile
Normal file
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
||||
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?
|
||||
177
ios/fastlane/Fastfile
Normal file
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
||||
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
|
||||
40
ios/fastlane/README.md
Normal file
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
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,8 +5,10 @@ derived_data := "build/DerivedData"
|
||||
default:
|
||||
@just build
|
||||
|
||||
build:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
generate:
|
||||
xcodegen --spec project.yml
|
||||
|
||||
build: generate
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
@@ -16,13 +18,15 @@ build:
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
|
||||
run:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
run: generate
|
||||
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}}'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||
@@ -57,6 +57,9 @@ model Chat {
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
|
||||
26
server/src/browser-fetch-headers.ts
Normal file
26
server/src/browser-fetch-headers.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const CHROMIUM_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
|
||||
|
||||
export const FETCH_URL_ACCEPT =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
|
||||
|
||||
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
|
||||
return {
|
||||
"User-Agent": CHROMIUM_USER_AGENT,
|
||||
Accept: accept,
|
||||
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
|
||||
return {
|
||||
...buildBrowserLikeRequestHeaders(accept),
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
};
|
||||
}
|
||||
@@ -6,10 +6,15 @@ import { promisify } from "node:util";
|
||||
import { convert as htmlToText } from "html-to-text";
|
||||
import type OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
||||
import {
|
||||
buildOpenAIConversationMessage,
|
||||
buildOpenAIResponsesInputMessage,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
} from "./message-content.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
@@ -188,7 +193,43 @@ const CHAT_TOOLS: any[] = [
|
||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
||||
];
|
||||
|
||||
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
function getToolName(tool: any) {
|
||||
return typeof tool?.function?.name === "string" ? tool.function.name : null;
|
||||
}
|
||||
|
||||
export function getAvailableChatTools() {
|
||||
return CHAT_TOOLS.map((tool) => {
|
||||
const name = getToolName(tool);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
|
||||
};
|
||||
}).filter((tool): tool is { name: string; description: string } => tool !== null);
|
||||
}
|
||||
|
||||
export function normalizeEnabledChatTools(value: unknown) {
|
||||
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
|
||||
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
|
||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||
available.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||
}
|
||||
|
||||
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return CHAT_TOOLS.filter((tool) => {
|
||||
const name = getToolName(tool);
|
||||
return name ? enabled.has(name) : false;
|
||||
});
|
||||
}
|
||||
|
||||
function toResponsesChatTools(tools: any[]) {
|
||||
return tools.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
return {
|
||||
type: "function",
|
||||
@@ -197,7 +238,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -239,6 +281,8 @@ type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||
@@ -249,15 +293,17 @@ type ToolAwareCompletionParams = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
|
||||
|
||||
export type ToolExecutionEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: ToolExecutionStatus;
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -285,10 +331,13 @@ function toSingleLine(value: string, maxLength = 220) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, 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.";
|
||||
}
|
||||
@@ -297,6 +346,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
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.";
|
||||
}
|
||||
@@ -305,6 +357,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
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.";
|
||||
}
|
||||
@@ -313,6 +368,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
|
||||
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.";
|
||||
}
|
||||
@@ -321,6 +379,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
||||
: `Devbox shell command failed.${errSuffix}`;
|
||||
}
|
||||
|
||||
if (status === "initiated") {
|
||||
return `Running tool '${name}'.`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return `Ran tool '${name}'.`;
|
||||
}
|
||||
@@ -379,20 +440,38 @@ function extractHtmlTitle(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||
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"> = {}) {
|
||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
||||
}
|
||||
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
@@ -492,10 +571,7 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
|
||||
response = await fetch(parsed.toString(), {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
||||
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
|
||||
},
|
||||
headers: buildBrowserLikeNavigationHeaders(),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -908,17 +984,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
||||
}));
|
||||
}
|
||||
|
||||
async function executeToolCallAndBuildEvent(
|
||||
call: NormalizedToolCall,
|
||||
params: ToolAwareCompletionParams
|
||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||
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 toolResult: ToolRunOutcome;
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
|
||||
let parseError: unknown;
|
||||
try {
|
||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||
toolResult = await executeTool(call.name, parsedArgs);
|
||||
} 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 }> {
|
||||
let toolResult: ToolRunOutcome;
|
||||
try {
|
||||
if (execution.parseError) throw execution.parseError;
|
||||
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||
} catch (err: any) {
|
||||
toolResult = {
|
||||
ok: false,
|
||||
@@ -935,16 +1049,15 @@ 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, eventArgs, status, error),
|
||||
args: eventArgs,
|
||||
startedAt,
|
||||
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||
args: execution.eventArgs,
|
||||
startedAt: execution.startedAt,
|
||||
completedAt: new Date(completedAtMs).toISOString(),
|
||||
durationMs: completedAtMs - startedAtMs,
|
||||
durationMs: completedAtMs - execution.startedAtMs,
|
||||
error,
|
||||
resultPreview: buildResultPreview(toolResult),
|
||||
};
|
||||
@@ -957,7 +1070,8 @@ async function executeToolCallAndBuildEvent(
|
||||
}
|
||||
|
||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -971,7 +1085,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1006,7 +1120,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
@@ -1026,7 +1141,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
}
|
||||
|
||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1040,7 +1156,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
@@ -1092,7 +1208,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
@@ -1114,7 +1231,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),
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
@@ -1134,7 +1251,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
|
||||
export async function* runToolAwareOpenAIChatStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1148,7 +1266,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
@@ -1235,7 +1353,9 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
@@ -1260,7 +1380,8 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
export async function* runToolAwareChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1274,7 +1395,7 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -1371,7 +1492,9 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
@@ -1403,7 +1526,7 @@ export async function* runPlainChatCompletionsStream(
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages),
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
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, """);
|
||||
}
|
||||
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||
return {
|
||||
role: "system",
|
||||
content: buildSystemPromptAugmentation(userLocation),
|
||||
};
|
||||
}
|
||||
|
||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||
"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[]) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -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, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildToolLogMessageData, normalizeEnabledChatTools, 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,13 +47,16 @@ 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") {
|
||||
if (req.provider === "openai" && enabledTools.length > 0) {
|
||||
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: {
|
||||
@@ -66,12 +69,14 @@ 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") {
|
||||
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
||||
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: {
|
||||
@@ -84,12 +89,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "hermes-agent") {
|
||||
const client = hermesAgentClient();
|
||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const r = await runPlainChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -104,7 +110,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const r = await client.messages.create({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
normalizeEnabledChatTools,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
@@ -76,12 +77,15 @@ 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"
|
||||
req.provider === "openai" && enabledTools.length > 0
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -90,11 +94,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: req.provider === "hermes-agent"
|
||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||
? runPlainChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -107,6 +112,8 @@ 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: {
|
||||
@@ -123,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
}
|
||||
|
||||
if (ev.type === "tool_call") {
|
||||
if (shouldPersist && chatId) {
|
||||
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
@@ -146,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages);
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const stream = await client.messages.create({
|
||||
|
||||
@@ -36,6 +36,9 @@ export type MultiplexRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -16,6 +17,8 @@ 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";
|
||||
@@ -48,6 +51,43 @@ 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;
|
||||
@@ -132,6 +172,9 @@ 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(),
|
||||
})
|
||||
@@ -156,6 +199,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
|
||||
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
|
||||
return {
|
||||
...body,
|
||||
additionalSystemPrompt,
|
||||
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
|
||||
};
|
||||
}
|
||||
|
||||
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
|
||||
body: T
|
||||
) {
|
||||
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
|
||||
return prependAdditionalSystemPrompt(body);
|
||||
}
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: { additionalSystemPrompt: true, enabledTools: true },
|
||||
});
|
||||
if (!chat) return prependAdditionalSystemPrompt(body);
|
||||
|
||||
return prependAdditionalSystemPrompt({
|
||||
...body,
|
||||
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
|
||||
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
|
||||
});
|
||||
}
|
||||
|
||||
const SearchRunBody = z.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
@@ -339,6 +417,8 @@ const chatSummarySelect = {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
@@ -716,6 +796,11 @@ 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 {
|
||||
@@ -746,6 +831,8 @@ 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) => {
|
||||
@@ -774,6 +861,8 @@ 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) => ({
|
||||
@@ -793,13 +882,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
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) });
|
||||
const Body = z.object({
|
||||
title: z.string().trim().min(1).optional(),
|
||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
|
||||
enabledTools: EnabledToolsSchema.optional(),
|
||||
});
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const 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: { title: body.title },
|
||||
data: data as any,
|
||||
});
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
@@ -1211,13 +1309,16 @@ 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 = parsed.data;
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -1230,7 +1331,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
const result = await runMultiplex(body);
|
||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||
|
||||
return {
|
||||
chatId: body.chatId ?? null,
|
||||
@@ -1244,7 +1345,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 = parsed.data;
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -1261,14 +1362,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, body);
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(body)) {
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const SEARXNG_TIMEOUT_MS = 12_000;
|
||||
@@ -106,10 +107,7 @@ async function fetchSearxng(url: URL, accept: string) {
|
||||
return await fetch(url, {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
|
||||
Accept: accept,
|
||||
},
|
||||
headers: buildBrowserLikeRequestHeaders(accept),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletions,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolAwareStreamingEvent,
|
||||
@@ -140,3 +141,142 @@ 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("fetch_url sends browser-like navigation headers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "fetch_url",
|
||||
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
choices: [{ message: { content: "Fetched" } }],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runToolAwareChatCompletions({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
|
||||
assert.deepEqual(fetchCalls[0]?.init?.headers, {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
});
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
||||
|
||||
test("system prompt augmentation includes date and default location", () => {
|
||||
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||
|
||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||
});
|
||||
|
||||
test("system prompt augmentation uses provided user location", () => {
|
||||
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||
|
||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||
});
|
||||
|
||||
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
||||
const prompt = getAnthropicSystemPrompt(
|
||||
[{ role: "system", content: "Use concise answers." }],
|
||||
"Los Angeles, CA"
|
||||
);
|
||||
|
||||
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||
assert.match(prompt, /Use concise answers\./);
|
||||
});
|
||||
@@ -124,6 +124,7 @@ export class SybilApiClient {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
@@ -32,7 +32,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -171,28 +171,47 @@ function isToolCallLogMessage(message: Message) {
|
||||
}
|
||||
|
||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
const metadata: ToolLogMetadata = {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
status: event.status,
|
||||
summary: event.summary,
|
||||
args: event.args,
|
||||
startedAt: event.startedAt,
|
||||
error: event.error ?? null,
|
||||
resultPreview: event.resultPreview ?? null,
|
||||
};
|
||||
|
||||
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 ?? new Date().toISOString(),
|
||||
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata: {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
status: event.status,
|
||||
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,
|
||||
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) {
|
||||
const providerModels = catalog[provider]?.models ?? [];
|
||||
if (providerModels.length) return providerModels;
|
||||
@@ -602,7 +621,12 @@ 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}" : "{cyan-fg}[tool]{/cyan-fg}";
|
||||
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 summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
|
||||
parts.push(`${prefix} ${escapeTags(summary)}`);
|
||||
continue;
|
||||
@@ -1083,29 +1107,7 @@ async function main() {
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
if (!pendingChatState) return;
|
||||
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),
|
||||
],
|
||||
};
|
||||
}
|
||||
pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
|
||||
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
|
||||
@@ -55,12 +55,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: "initiated" | "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
|
||||
656
web/src/App.tsx
656
web/src/App.tsx
@@ -1,5 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe2,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rabbit,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Settings2,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -18,6 +35,7 @@ import {
|
||||
attachSearchStream,
|
||||
getActiveRuns,
|
||||
getChat,
|
||||
listChatTools,
|
||||
listModels,
|
||||
getSearch,
|
||||
listWorkspaceItems,
|
||||
@@ -27,6 +45,7 @@ import {
|
||||
updateChatTitle,
|
||||
updateChatStar,
|
||||
updateSearchStar,
|
||||
updateChatSettings,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ActiveRunsResponse,
|
||||
@@ -34,6 +53,7 @@ import {
|
||||
type Provider,
|
||||
type ChatDetail,
|
||||
type ChatSummary,
|
||||
type ChatToolInfo,
|
||||
type CompletionRequestMessage,
|
||||
type Message,
|
||||
type SearchDetail,
|
||||
@@ -379,6 +399,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function getToolLabel(name: string) {
|
||||
if (name === "web_search") return "Web search";
|
||||
if (name === "fetch_url") return "Fetch URL";
|
||||
if (name === "codex_exec") return "Codex";
|
||||
if (name === "shell_exec") return "Shell";
|
||||
return name
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
|
||||
return availableTools.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
||||
const available = new Set(availableTools.map((tool) => tool.name));
|
||||
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
|
||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
||||
available.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||
return {
|
||||
@@ -391,7 +435,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -417,28 +461,48 @@ function isDisplayableMessage(message: Message) {
|
||||
}
|
||||
|
||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
const metadata: ToolLogMetadata = {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
status: event.status,
|
||||
summary: event.summary,
|
||||
args: event.args,
|
||||
startedAt: event.startedAt,
|
||||
error: event.error ?? null,
|
||||
resultPreview: event.resultPreview ?? null,
|
||||
};
|
||||
|
||||
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 ?? new Date().toISOString(),
|
||||
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||
role: "tool",
|
||||
content: event.summary,
|
||||
name: event.name,
|
||||
metadata: {
|
||||
kind: "tool_call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
status: event.status,
|
||||
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,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent, assistantMessagePrefix: string) {
|
||||
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(assistantMessagePrefix)
|
||||
);
|
||||
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||
}
|
||||
|
||||
type ModelComboboxProps = {
|
||||
options: string[];
|
||||
value: string;
|
||||
@@ -748,6 +812,7 @@ export default function App() {
|
||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||
const [model, setModel] = useState(() => {
|
||||
const stored = loadStoredModelPreferences();
|
||||
@@ -774,6 +839,18 @@ export default function App() {
|
||||
const [renameChatDraft, setRenameChatDraft] = useState("");
|
||||
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
||||
const [isSavingChatSettings, setIsSavingChatSettings] = useState(false);
|
||||
const [isTogglingChatSettingsStar, setIsTogglingChatSettingsStar] = useState(false);
|
||||
const [chatSettingsError, setChatSettingsError] = useState<string | null>(null);
|
||||
const [draftChatTitle, setDraftChatTitle] = useState("");
|
||||
const [chatSettingsTitleDraft, setChatSettingsTitleDraft] = useState("");
|
||||
const [chatSettingsProviderDraft, setChatSettingsProviderDraft] = useState<Provider>("openai");
|
||||
const [chatSettingsModelDraft, setChatSettingsModelDraft] = useState("");
|
||||
const [chatSettingsPromptDraft, setChatSettingsPromptDraft] = useState("");
|
||||
const [chatSettingsEnabledToolsDraft, setChatSettingsEnabledToolsDraft] = useState<string[]>([]);
|
||||
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
||||
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -899,6 +976,18 @@ export default function App() {
|
||||
searchRunCountersRef.current.clear();
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsChatSettingsOpen(false);
|
||||
setIsSavingChatSettings(false);
|
||||
setIsTogglingChatSettingsStar(false);
|
||||
setChatSettingsError(null);
|
||||
setDraftChatTitle("");
|
||||
setChatSettingsTitleDraft("");
|
||||
setChatSettingsProviderDraft("openai");
|
||||
setChatSettingsModelDraft("");
|
||||
setChatSettingsPromptDraft("");
|
||||
setChatSettingsEnabledToolsDraft([]);
|
||||
setAdditionalSystemPrompt("");
|
||||
setEnabledTools([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setQuickPrompt("");
|
||||
setQuickSubmittedPrompt(null);
|
||||
@@ -968,6 +1057,21 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshChatTools = async () => {
|
||||
try {
|
||||
const tools = await listChatTools();
|
||||
setAvailableChatTools(tools);
|
||||
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshActiveRuns = async () => {
|
||||
try {
|
||||
const data = await getActiveRuns();
|
||||
@@ -1020,7 +1124,7 @@ export default function App() {
|
||||
if (!isAuthenticated) return;
|
||||
const preferredSelection = initialRouteSelectionRef.current;
|
||||
initialRouteSelectionRef.current = null;
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1065,6 +1169,10 @@ export default function App() {
|
||||
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||
const chatSettingsProviderModelOptions = useMemo(
|
||||
() => getModelOptions(modelCatalog, chatSettingsProviderDraft),
|
||||
[chatSettingsProviderDraft, modelCatalog]
|
||||
);
|
||||
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1267,11 +1375,6 @@ export default function App() {
|
||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||
}, [chats, selectedItem]);
|
||||
|
||||
const selectedSidebarItem = useMemo(() => {
|
||||
if (!selectedItem) return null;
|
||||
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
|
||||
}, [selectedItem, sidebarItems]);
|
||||
|
||||
const selectedSearchSummary = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||
@@ -1287,8 +1390,17 @@ export default function App() {
|
||||
setModel(nextSelection.model);
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftKind === "chat") return;
|
||||
if (selectedItem?.kind !== "chat") return;
|
||||
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
||||
if (!chat) return;
|
||||
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
|
||||
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
|
||||
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||
|
||||
const selectedTitle = useMemo(() => {
|
||||
if (draftKind === "chat") return "New chat";
|
||||
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
|
||||
if (draftKind === "search") return "New search";
|
||||
if (!selectedItem) return "Sybil";
|
||||
if (selectedItem.kind === "chat") {
|
||||
@@ -1299,7 +1411,7 @@ export default function App() {
|
||||
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
||||
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
||||
return "New search";
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||
}, [draftChatTitle, draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (draftKind || !selectedItem) return "Sybil";
|
||||
@@ -1331,6 +1443,11 @@ export default function App() {
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setDraftChatTitle("");
|
||||
setAdditionalSystemPrompt("");
|
||||
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
||||
setIsChatSettingsOpen(false);
|
||||
setChatSettingsError(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -1348,6 +1465,8 @@ export default function App() {
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setIsChatSettingsOpen(false);
|
||||
setChatSettingsError(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -1441,6 +1560,8 @@ export default function App() {
|
||||
initiatedModel: updatedChat.initiatedModel,
|
||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
||||
lastUsedModel: updatedChat.lastUsedModel,
|
||||
additionalSystemPrompt: updatedChat.additionalSystemPrompt,
|
||||
enabledTools: updatedChat.enabledTools,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1476,6 +1597,99 @@ export default function App() {
|
||||
setRenameChatDialog({ chatId });
|
||||
};
|
||||
|
||||
const getChatSettingsSeedTitle = () => {
|
||||
if (draftKind === "chat") return draftChatTitle;
|
||||
if (selectedItem?.kind === "chat") {
|
||||
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
|
||||
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
|
||||
}
|
||||
return draftChatTitle;
|
||||
};
|
||||
|
||||
const openChatSettings = () => {
|
||||
if (isSearchMode) return;
|
||||
setContextMenu(null);
|
||||
setRenameChatDialog(null);
|
||||
setChatSettingsError(null);
|
||||
setChatSettingsTitleDraft(getChatSettingsSeedTitle());
|
||||
setChatSettingsProviderDraft(provider);
|
||||
setChatSettingsModelDraft(model);
|
||||
setChatSettingsPromptDraft(additionalSystemPrompt);
|
||||
setChatSettingsEnabledToolsDraft(normalizeEnabledTools(enabledTools, availableChatTools));
|
||||
setIsChatSettingsOpen(true);
|
||||
};
|
||||
|
||||
const toggleChatSettingsTool = (toolName: string) => {
|
||||
setChatSettingsEnabledToolsDraft((current) => {
|
||||
if (current.includes(toolName)) return current.filter((name) => name !== toolName);
|
||||
return current.concat(toolName);
|
||||
});
|
||||
};
|
||||
|
||||
const commitLocalChatSettings = (nextProvider: Provider, nextModel: string, nextPrompt: string, nextTools: string[], nextTitle: string) => {
|
||||
setProvider(nextProvider);
|
||||
setModel(nextModel);
|
||||
setProviderModelPreferences((current) => ({
|
||||
...current,
|
||||
[nextProvider]: nextModel || null,
|
||||
}));
|
||||
setAdditionalSystemPrompt(nextPrompt);
|
||||
setEnabledTools(nextTools);
|
||||
setDraftChatTitle(nextTitle);
|
||||
};
|
||||
|
||||
const handleChatSettingsSubmit = async (event?: Event) => {
|
||||
event?.preventDefault();
|
||||
if (isSavingChatSettings) return;
|
||||
|
||||
const nextModel = chatSettingsModelDraft.trim();
|
||||
if (!nextModel) {
|
||||
setChatSettingsError("Enter a model.");
|
||||
return;
|
||||
}
|
||||
|
||||
const existingChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
const isExistingChat = existingChatId !== null;
|
||||
const nextTitle = chatSettingsTitleDraft.trim();
|
||||
if (isExistingChat && !nextTitle) {
|
||||
setChatSettingsError("Enter a chat title.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrompt = chatSettingsPromptDraft.trim();
|
||||
const nextTools = availableChatTools.length
|
||||
? normalizeEnabledTools(chatSettingsEnabledToolsDraft, availableChatTools)
|
||||
: chatSettingsEnabledToolsDraft;
|
||||
|
||||
setIsSavingChatSettings(true);
|
||||
setChatSettingsError(null);
|
||||
setError(null);
|
||||
try {
|
||||
if (isExistingChat) {
|
||||
const updatedChat = await updateChatSettings(existingChatId, {
|
||||
title: nextTitle,
|
||||
additionalSystemPrompt: nextPrompt || null,
|
||||
...(availableChatTools.length ? { enabledTools: nextTools } : {}),
|
||||
});
|
||||
applyChatSummary(updatedChat);
|
||||
} else if (!selectedItem && draftKind !== "chat") {
|
||||
setDraftKind("chat");
|
||||
}
|
||||
|
||||
commitLocalChatSettings(chatSettingsProviderDraft, nextModel, nextPrompt, nextTools, nextTitle);
|
||||
setIsChatSettingsOpen(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setChatSettingsError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsSavingChatSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
const menuWidth = 176;
|
||||
@@ -1540,6 +1754,29 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChatSettingsStar = async () => {
|
||||
if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return;
|
||||
const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id);
|
||||
const nextStarred = !current?.starred;
|
||||
setIsTogglingChatSettingsStar(true);
|
||||
setChatSettingsError(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedChat = await updateChatStar(selectedItem.id, nextStarred);
|
||||
applyChatSummary(updatedChat, false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setChatSettingsError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsTogglingChatSettingsStar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromContextMenu = async () => {
|
||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||
const target = contextMenu.item;
|
||||
@@ -1588,6 +1825,17 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renameChatDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatSettingsOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
||||
event.preventDefault();
|
||||
setIsChatSettingsOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1748,9 +1996,17 @@ export default function App() {
|
||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
|
||||
if (!chatId) {
|
||||
const chat = await createChat();
|
||||
const initialEnabledTools = availableChatTools.length ? normalizeEnabledTools(enabledTools, availableChatTools) : undefined;
|
||||
const chat = await createChat({
|
||||
...(draftChatTitle.trim() ? { title: draftChatTitle.trim() } : {}),
|
||||
provider,
|
||||
model: selectedModel,
|
||||
...(additionalSystemPrompt.trim() ? { additionalSystemPrompt: additionalSystemPrompt.trim() } : {}),
|
||||
...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}),
|
||||
});
|
||||
chatId = chat.id;
|
||||
setDraftKind(null);
|
||||
setDraftChatTitle("");
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
@@ -1768,6 +2024,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
@@ -1855,33 +2113,10 @@ export default function App() {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
if (
|
||||
pendingState.messages.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = pendingState.messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
if (assistantIndex < 0) {
|
||||
return {
|
||||
...current,
|
||||
[chatId]: { messages: pendingState.messages.concat(toolMessage) },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: [
|
||||
...pendingState.messages.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...pendingState.messages.slice(assistantIndex),
|
||||
],
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -2121,30 +2356,10 @@ export default function App() {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
if (
|
||||
pendingState.messages.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = pendingState.messages.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||
);
|
||||
if (assistantIndex < 0) {
|
||||
return { ...current, [chatId]: { messages: pendingState.messages.concat(toolMessage) } };
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: [
|
||||
...pendingState.messages.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...pendingState.messages.slice(assistantIndex),
|
||||
],
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -2349,6 +2564,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
@@ -2409,25 +2626,7 @@ export default function App() {
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = current.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
|
||||
);
|
||||
if (assistantIndex < 0) return current.concat(toolMessage);
|
||||
return [
|
||||
...current.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...current.slice(assistantIndex),
|
||||
];
|
||||
return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
@@ -2527,6 +2726,8 @@ export default function App() {
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
||||
enabledTools: chat.enabledTools,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
@@ -2595,6 +2796,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
const chatSettingsStarred = chatSettingsChatId
|
||||
? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false
|
||||
: false;
|
||||
|
||||
if (isCheckingSession) {
|
||||
return (
|
||||
@@ -2773,8 +2978,8 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
|
||||
<div className="flex items-start gap-2">
|
||||
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
@@ -2788,68 +2993,24 @@ export default function App() {
|
||||
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
{draftKind === null && selectedItem ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
||||
onClick={() => void handleToggleStar(selectedItem)}
|
||||
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
>
|
||||
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
|
||||
</Button>
|
||||
) : null}
|
||||
{draftKind === null && selectedItem?.kind === "chat" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
||||
onClick={() => openRenameChatDialog(selectedItem.id)}
|
||||
title="Rename chat"
|
||||
aria-label="Rename chat"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
{!isSearchMode ? (
|
||||
<>
|
||||
<select
|
||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={provider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isActiveSelectionSending}
|
||||
>
|
||||
{providerOptions.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{getProviderLabel(candidate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ModelCombobox
|
||||
options={providerModelOptions}
|
||||
value={model}
|
||||
disabled={isActiveSelectionSending}
|
||||
onChange={(nextModel) => {
|
||||
const normalizedModel = nextModel.trim();
|
||||
setModel(normalizedModel);
|
||||
setProviderModelPreferences((current) => ({
|
||||
...current,
|
||||
[provider]: normalizedModel || null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 max-w-[44vw] gap-2 rounded-lg px-3 md:max-w-full"
|
||||
onClick={openChatSettings}
|
||||
disabled={isActiveSelectionSending}
|
||||
aria-label="Open chat settings"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden shrink-0 sm:inline">Settings</span>
|
||||
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
|
||||
{getProviderLabel(provider)} · {model || "No model"}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
||||
<Globe2 className="mr-2 h-4 w-4" />
|
||||
@@ -3021,6 +3182,201 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isChatSettingsOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="chat-settings-title"
|
||||
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
||||
Chat settings
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsChatSettingsOpen(false)}
|
||||
disabled={isSavingChatSettings}
|
||||
aria-label="Close chat settings"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={chatSettingsTitleDraft}
|
||||
onInput={(event) => {
|
||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||
if (chatSettingsError) setChatSettingsError(null);
|
||||
}}
|
||||
maxLength={120}
|
||||
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
{chatSettingsChatId ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-11 w-11 shrink-0 rounded-lg"
|
||||
onClick={() => void handleToggleChatSettingsStar()}
|
||||
disabled={isSavingChatSettings || isTogglingChatSettingsStar}
|
||||
aria-label={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
||||
title={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
||||
>
|
||||
{isTogglingChatSettingsStar ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Star className={cn("h-4 w-4", chatSettingsStarred ? "fill-amber-300 text-amber-300" : "")} />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(9rem,0.7fr)_minmax(14rem,1fr)]">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={chatSettingsProviderDraft}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setChatSettingsProviderDraft(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setChatSettingsModelDraft(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||
setChatSettingsError(null);
|
||||
}}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
{providerOptions.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{getProviderLabel(candidate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||
<ModelCombobox
|
||||
options={chatSettingsProviderModelOptions}
|
||||
value={chatSettingsModelDraft}
|
||||
disabled={isSavingChatSettings}
|
||||
onChange={(nextModel) => {
|
||||
setChatSettingsModelDraft(nextModel.trim());
|
||||
setChatSettingsError(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Additional system prompt</span>
|
||||
<Textarea
|
||||
rows={5}
|
||||
value={chatSettingsPromptDraft}
|
||||
onInput={(event) => {
|
||||
setChatSettingsPromptDraft(event.currentTarget.value);
|
||||
if (chatSettingsError) setChatSettingsError(null);
|
||||
}}
|
||||
placeholder="Add per-chat instructions"
|
||||
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 className="text-xs font-semibold text-violet-100/72">Tools</h3>
|
||||
{availableChatTools.length ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{availableChatTools.length ? (
|
||||
availableChatTools.map((tool) => {
|
||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||
return (
|
||||
<label
|
||||
key={tool.name}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
||||
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
No chat tools are available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSavingChatSettings}>
|
||||
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{renameChatDialog ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Globe2, Link2, Wrench } from "lucide-preact";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
type Props = {
|
||||
messages: Message[];
|
||||
@@ -14,7 +16,7 @@ type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "initiated" | "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
@@ -71,9 +73,40 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
||||
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
|
||||
type ToolStackStyle = JSX.CSSProperties & {
|
||||
"--tool-stack-x"?: string;
|
||||
"--tool-stack-y"?: string;
|
||||
"--tool-stack-z"?: string;
|
||||
"--tool-stack-scale"?: string;
|
||||
"--tool-stack-opacity"?: string;
|
||||
"--tool-stack-delay"?: string;
|
||||
"--tool-stack-from-transform"?: string;
|
||||
"--tool-stack-to-transform"?: string;
|
||||
"--tool-stack-from-opacity"?: string;
|
||||
"--tool-stack-to-opacity"?: string;
|
||||
};
|
||||
type ToolStackContainerStyle = JSX.CSSProperties & {
|
||||
"--tool-stack-from-height"?: string;
|
||||
"--tool-stack-to-height"?: string;
|
||||
};
|
||||
type ToolStackMotionDirection = "expand" | "collapse" | null;
|
||||
|
||||
const COLLAPSED_TOOL_STACK_LIMIT = 4;
|
||||
const TOOL_STACK_CARD_HEIGHT = 62;
|
||||
const TOOL_STACK_CARD_GAP = 10;
|
||||
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
|
||||
|
||||
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) {
|
||||
return [
|
||||
isFailed ? "Failed" : "Completed",
|
||||
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||
formatDuration(metadata.durationMs),
|
||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||
]
|
||||
@@ -81,53 +114,293 @@ function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFaile
|
||||
.join(" • ");
|
||||
}
|
||||
|
||||
function buildMessageRenderItems(messages: Message[]) {
|
||||
const items: MessageRenderItem[] = [];
|
||||
let toolRun: Message[] = [];
|
||||
|
||||
const flushToolRun = () => {
|
||||
if (!toolRun.length) return;
|
||||
if (toolRun.length === 1) {
|
||||
items.push({ kind: "message", message: toolRun[0] });
|
||||
} else {
|
||||
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
|
||||
}
|
||||
toolRun = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
|
||||
toolRun.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
items.push({ kind: "message", message });
|
||||
}
|
||||
|
||||
flushToolRun();
|
||||
return items;
|
||||
}
|
||||
|
||||
function getToolStackHeight(messageCount: number, expanded: boolean) {
|
||||
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||
return expanded
|
||||
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
|
||||
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
|
||||
}
|
||||
|
||||
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
|
||||
const collapsedHeight = getToolStackHeight(messageCount, false);
|
||||
const expandedHeight = getToolStackHeight(messageCount, true);
|
||||
const targetHeight = expanded ? expandedHeight : collapsedHeight;
|
||||
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
|
||||
|
||||
return {
|
||||
"--tool-stack-from-height": fromHeight,
|
||||
"--tool-stack-to-height": targetHeight,
|
||||
height: targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpandedToolLayout(index: number, messageCount: number) {
|
||||
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
|
||||
return {
|
||||
opacity: "1",
|
||||
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
|
||||
x: "0px",
|
||||
y,
|
||||
z: "0px",
|
||||
scale: "1",
|
||||
zIndex: messageCount - index,
|
||||
};
|
||||
}
|
||||
|
||||
function getCollapsedToolLayout(index: number, messageCount: number) {
|
||||
const depth = messageCount - index - 1;
|
||||
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
|
||||
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||
const x = `${visibleDepth * 11}px`;
|
||||
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
|
||||
const z = `${visibleDepth * -36}px`;
|
||||
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
|
||||
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
|
||||
|
||||
return {
|
||||
opacity,
|
||||
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
scale,
|
||||
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
|
||||
};
|
||||
}
|
||||
|
||||
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
|
||||
const expandedLayout = getExpandedToolLayout(index, messageCount);
|
||||
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
|
||||
const targetLayout = expanded ? expandedLayout : collapsedLayout;
|
||||
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
|
||||
|
||||
return {
|
||||
"--tool-stack-x": targetLayout.x,
|
||||
"--tool-stack-y": targetLayout.y,
|
||||
"--tool-stack-z": targetLayout.z,
|
||||
"--tool-stack-scale": targetLayout.scale,
|
||||
"--tool-stack-opacity": targetLayout.opacity,
|
||||
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
|
||||
"--tool-stack-from-transform": fromLayout.transform,
|
||||
"--tool-stack-to-transform": targetLayout.transform,
|
||||
"--tool-stack-from-opacity": fromLayout.opacity,
|
||||
"--tool-stack-to-opacity": targetLayout.opacity,
|
||||
opacity: targetLayout.opacity,
|
||||
transform: targetLayout.transform,
|
||||
zIndex: targetLayout.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function ToolCallCard({
|
||||
message,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
message: Message;
|
||||
className?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
}) {
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (!toolLogMetadata) return null;
|
||||
|
||||
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 toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex 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))]",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed
|
||||
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||
: isInitiated
|
||||
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallStack({
|
||||
groupKey,
|
||||
messages,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
groupKey: string;
|
||||
messages: Message[];
|
||||
expanded: boolean;
|
||||
onToggle: (groupKey: string) => void;
|
||||
}) {
|
||||
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
|
||||
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
|
||||
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
|
||||
const [motionRevision, setMotionRevision] = useState(0);
|
||||
const motionResetTimerRef = useRef<number | null>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setMotionDirection(expanded ? "collapse" : "expand");
|
||||
setMotionRevision((current) => current + 1);
|
||||
if (typeof window !== "undefined") {
|
||||
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
|
||||
motionResetTimerRef.current = window.setTimeout(() => {
|
||||
setMotionDirection(null);
|
||||
motionResetTimerRef.current = null;
|
||||
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
|
||||
}
|
||||
onToggle(groupKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
|
||||
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
|
||||
)}
|
||||
data-tool-stack-group={groupKey}
|
||||
data-expanded={expanded ? "true" : "false"}
|
||||
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const depth = messages.length - index - 1;
|
||||
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
|
||||
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
|
||||
isHidden && "pointer-events-none"
|
||||
)}
|
||||
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
|
||||
aria-hidden={isHidden ? "true" : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn("tool-call-stack-card-surface", !isHidden && "tool-call-stack-card-enter")}
|
||||
data-tool-stack-card-id={message.id}
|
||||
>
|
||||
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!expanded && hiddenCount ? (
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
aria-expanded={expanded ? "true" : "false"}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const toggleToolGroup = (groupKey: string) => {
|
||||
setExpandedToolGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupKey)) next.delete(groupKey);
|
||||
else next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{messages.map((message) => {
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === "tool_group") {
|
||||
return (
|
||||
<ToolCallStack
|
||||
key={`tool-group-${item.key}`}
|
||||
groupKey={item.key}
|
||||
messages={item.messages}
|
||||
expanded={expandedToolGroups.has(item.key)}
|
||||
onToggle={toggleToolGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { message } = item;
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const isFailed = toolLogMetadata.status === "failed";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[85%] 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))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||
)}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
|
||||
{toolSummary}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ToolCallCard message={message} className="max-w-[85%]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,148 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.tool-call-stack-card {
|
||||
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1));
|
||||
transform-origin: top left;
|
||||
opacity: var(--tool-stack-opacity, 1);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-a {
|
||||
animation: tool-call-stack-height-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-b {
|
||||
animation: tool-call-stack-height-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-layout-a {
|
||||
animation: tool-call-stack-layout-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-layout-b {
|
||||
animation: tool-call-stack-layout-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-surface {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.tool-call-stack-card-glass {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.tool-call-stack-card-enter {
|
||||
animation: tool-call-stack-drop-in 320ms cubic-bezier(0.18, 0.95, 0.28, 1) backwards;
|
||||
animation-delay: var(--tool-stack-delay, 0ms);
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle {
|
||||
border: 1px solid hsl(188 82% 70% / 0.36);
|
||||
background:
|
||||
linear-gradient(180deg, hsl(230 36% 16% / 0.96), hsl(238 48% 7% / 0.96)),
|
||||
hsl(236 48% 8%);
|
||||
color: hsl(186 92% 86%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(180 100% 88% / 0.08),
|
||||
0 8px 22px hsl(235 72% 2% / 0.42);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease,
|
||||
filter 160ms ease;
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle:hover {
|
||||
border-color: hsl(188 92% 74% / 0.62);
|
||||
color: hsl(184 100% 92%);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.tool-call-stack-toggle:focus-visible {
|
||||
outline: 2px solid hsl(188 92% 72% / 0.9);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-height-a {
|
||||
from {
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
to {
|
||||
height: var(--tool-stack-to-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-height-b {
|
||||
from {
|
||||
height: var(--tool-stack-from-height);
|
||||
}
|
||||
|
||||
to {
|
||||
height: var(--tool-stack-to-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-layout-a {
|
||||
from {
|
||||
opacity: var(--tool-stack-from-opacity, 1);
|
||||
transform: var(--tool-stack-from-transform);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: var(--tool-stack-to-opacity, 1);
|
||||
transform: var(--tool-stack-to-transform);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-layout-b {
|
||||
from {
|
||||
opacity: var(--tool-stack-from-opacity, 1);
|
||||
transform: var(--tool-stack-from-transform);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: var(--tool-stack-to-opacity, 1);
|
||||
transform: var(--tool-stack-to-transform);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-call-stack-drop-in {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
transform: translate3d(0, -0.65rem, 120px) scale(1.025) rotateX(3deg);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-call-stack-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell-layout-a,
|
||||
.tool-call-stack-shell-layout-b,
|
||||
.tool-call-stack-card-layout-a,
|
||||
.tool-call-stack-card-layout-b,
|
||||
.tool-call-stack-card-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.md-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type ChatSummary = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
};
|
||||
|
||||
export type SearchSummary = {
|
||||
@@ -43,12 +45,12 @@ export type Message = {
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: "completed" | "failed";
|
||||
status: "initiated" | "completed" | "failed";
|
||||
summary: string;
|
||||
args: Record<string, unknown>;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
durationMs: number;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
resultPreview?: string;
|
||||
};
|
||||
@@ -64,6 +66,8 @@ export type ChatDetail = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -157,6 +161,11 @@ export type ModelCatalogResponse = {
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ActiveRunsResponse = {
|
||||
chats: string[];
|
||||
searches: string[];
|
||||
@@ -182,6 +191,8 @@ type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
@@ -257,6 +268,11 @@ 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");
|
||||
}
|
||||
@@ -291,6 +307,17 @@ export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
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",
|
||||
@@ -613,6 +640,9 @@ 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",
|
||||
@@ -627,6 +657,9 @@ export async function runCompletionStream(
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
Reference in New Issue
Block a user