Compare commits
13 Commits
c47646a48c
...
codex/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
| 85f8d6b5f3 | |||
| 05989e9fae | |||
| 70b831557f | |||
| cb368a4005 | |||
| adb9e15b6c | |||
| dacab2f6ee | |||
| a31c053298 | |||
| be6641de24 | |||
| 72b3a0902a | |||
| 49e30296b9 | |||
| e3253d1741 | |||
| 991316e692 | |||
| d5b06ce22a |
@@ -112,6 +112,12 @@ Behavior notes:
|
|||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
- 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.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
|
- For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction.
|
||||||
|
- Available tool calls for chat: `web_search` and `fetch_url`.
|
||||||
|
- `web_search` uses Exa and returns ranked results with per-result summaries/snippets.
|
||||||
|
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
|
||||||
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output.
|
||||||
|
- `anthropic` currently runs without server-managed tool calls.
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
|
|
||||||
@@ -171,7 +177,8 @@ Search run notes:
|
|||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"role": "system|user|assistant|tool",
|
"role": "system|user|assistant|tool",
|
||||||
"content": "...",
|
"content": "...",
|
||||||
"name": null
|
"name": null,
|
||||||
|
"metadata": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ Notes:
|
|||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
1. Exactly one `meta`
|
1. Exactly one `meta`
|
||||||
2. Zero or more `delta`
|
2. Zero or more `tool_call`
|
||||||
3. Exactly one terminal event: `done` or `error`
|
3. Zero or more `delta`
|
||||||
|
4. Exactly one terminal event: `done` or `error`
|
||||||
|
|
||||||
### `meta`
|
### `meta`
|
||||||
|
|
||||||
@@ -60,6 +61,23 @@ Event order:
|
|||||||
|
|
||||||
`text` may contain partial words, punctuation, or whitespace.
|
`text` may contain partial words, punctuation, or whitespace.
|
||||||
|
|
||||||
|
### `tool_call`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toolCallId": "call_123",
|
||||||
|
"name": "web_search",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "Performed web search for 'latest CPI release'.",
|
||||||
|
"args": { "query": "latest CPI release" },
|
||||||
|
"startedAt": "2026-03-02T10:00:00.000Z",
|
||||||
|
"completedAt": "2026-03-02T10:00:00.820Z",
|
||||||
|
"durationMs": 820,
|
||||||
|
"error": null,
|
||||||
|
"resultPreview": "{\"ok\":true,...}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `done`
|
### `done`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -84,10 +102,15 @@ Event order:
|
|||||||
|
|
||||||
## Provider Streaming Behavior
|
## Provider Streaming Behavior
|
||||||
|
|
||||||
- `openai`: streamed via OpenAI chat completion chunks; emits `delta` from `choices[0].delta.content`.
|
- `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) before producing final text.
|
||||||
- `xai`: uses OpenAI-compatible API, same chunk extraction as OpenAI.
|
- `xai`: same tool-enabled behavior as OpenAI.
|
||||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
|
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
|
||||||
|
|
||||||
|
Tool-enabled streaming notes (`openai`/`xai`):
|
||||||
|
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||||
|
- Stream may emit `tool_call` events while tool calls are executed.
|
||||||
|
- `delta` events stream incrementally as text is generated.
|
||||||
|
|
||||||
## Persistence + Consistency Model
|
## Persistence + Consistency Model
|
||||||
|
|
||||||
Backend database remains source of truth.
|
Backend database remains source of truth.
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change.
|
- If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change.
|
||||||
|
|
||||||
## Practical Notes
|
## Practical Notes
|
||||||
- Default API URL is `http://127.0.0.1:8787/api` (configurable in-app).
|
- Default API URL is `http://127.0.0.1:8787` (configurable in-app).
|
||||||
|
- Previously saved `/api` API roots are normalized to the server root by the iOS client.
|
||||||
- Provider fallback models:
|
- Provider fallback models:
|
||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
|
|||||||
BIN
ios/Apps/Sybil/Resources/Fonts/Inter.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/Inter.ttf
Normal file
Binary file not shown.
BIN
ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf
Normal file
Binary file not shown.
@@ -11,9 +11,9 @@ targets:
|
|||||||
product: Sybil
|
product: Sybil
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.sybil.app
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
PRODUCT_MODULE_NAME: SybilApp
|
PRODUCT_MODULE_NAME: SybilApp
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
SWIFT_VERSION: 6.0
|
SWIFT_VERSION: 6.0
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ public struct SplitView: View {
|
|||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
public init() {}
|
public init() {
|
||||||
|
SybilFontRegistry.registerIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -31,6 +33,7 @@ public struct SplitView: View {
|
|||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.font(.sybil(.body))
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ actor SybilAPIClient {
|
|||||||
case "meta":
|
case "meta":
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
await onEvent(.meta(payload))
|
await onEvent(.meta(payload))
|
||||||
|
case "tool_call":
|
||||||
|
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||||
|
await onEvent(.toolCall(payload))
|
||||||
case "delta":
|
case "delta":
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
await onEvent(.delta(payload))
|
await onEvent(.delta(payload))
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ struct SybilChatTranscriptView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 24) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
if isLoading && messages.isEmpty {
|
if isLoading && messages.isEmpty {
|
||||||
Text("Loading messages…")
|
Text("Loading messages…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.tint(SybilTheme.textMuted)
|
.tint(SybilTheme.textMuted)
|
||||||
Text("Assistant is typing…")
|
Text("Assistant is typing…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.id("typing-indicator")
|
.id("typing-indicator")
|
||||||
@@ -71,6 +71,10 @@ private struct MessageBubble: View {
|
|||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
|
message.toolCallMetadata
|
||||||
|
}
|
||||||
|
|
||||||
private var isUser: Bool {
|
private var isUser: Bool {
|
||||||
message.role == .user
|
message.role == .user
|
||||||
}
|
}
|
||||||
@@ -82,15 +86,26 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(alignment: .top) {
|
||||||
|
if isUser {
|
||||||
|
Spacer(minLength: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let toolCallMetadata {
|
||||||
|
ToolCallActivityChip(
|
||||||
|
metadata: toolCallMetadata,
|
||||||
|
fallbackContent: message.content,
|
||||||
|
createdAt: message.createdAt
|
||||||
|
)
|
||||||
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if isPendingAssistant {
|
if isPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.tint(SybilTheme.textMuted)
|
.tint(SybilTheme.primary)
|
||||||
Text("Thinking…")
|
Text("Thinking…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -98,18 +113,16 @@ private struct MessageBubble: View {
|
|||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
.markdownTextStyle {
|
.markdownTheme(.sybilReadable)
|
||||||
FontSize(15)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 11 : 2)
|
.padding(.vertical, isUser ? 13 : 2)
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
if isUser {
|
if isUser {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(SybilTheme.userBubble.opacity(0.86))
|
.fill(SybilTheme.userBubbleGradient)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||||
@@ -122,5 +135,119 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isUser {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToolCallActivityChip: View {
|
||||||
|
var metadata: ToolCallMetadata
|
||||||
|
var fallbackContent: String
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
private var summary: String {
|
||||||
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if (metadata.status ?? "").lowercased() == "failed",
|
||||||
|
let error = metadata.error?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!error.isEmpty {
|
||||||
|
return "Tool failed: \(error)"
|
||||||
|
}
|
||||||
|
if let preview = metadata.resultPreview?.trimmingCharacters(in: .whitespacesAndNewlines), !preview.isEmpty {
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return fallbackContent
|
||||||
|
}
|
||||||
|
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolLabel: String {
|
||||||
|
let raw = metadata.toolName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !raw.isEmpty else { return "Tool call" }
|
||||||
|
return raw
|
||||||
|
.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.split(separator: " ")
|
||||||
|
.map { word in
|
||||||
|
word.prefix(1).uppercased() + word.dropFirst()
|
||||||
|
}
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
let name = (metadata.toolName ?? "").lowercased()
|
||||||
|
if name.contains("search") {
|
||||||
|
return "globe"
|
||||||
|
}
|
||||||
|
if name.contains("url") || name.contains("fetch") || name.contains("http") {
|
||||||
|
return "link"
|
||||||
|
}
|
||||||
|
return "wrench.and.screwdriver"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isFailed: Bool {
|
||||||
|
(metadata.status ?? "").lowercased() == "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detailLabel: String {
|
||||||
|
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||||
|
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||||
|
pieces.append("\(durationMs) ms")
|
||||||
|
}
|
||||||
|
pieces.append(createdAt.sybilShortTimeLabel)
|
||||||
|
return pieces.joined(separator: " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 11) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 9)
|
||||||
|
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 9)
|
||||||
|
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||||
|
)
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||||
|
}
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(summary)
|
||||||
|
.font(.sybil(.subheadline))
|
||||||
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(toolLabel)
|
||||||
|
.font(.sybil(.caption2, weight: .semibold))
|
||||||
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(detailLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: 520, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,32 @@ struct SybilConnectionView: View {
|
|||||||
@Bindable var settings = viewModel.settings
|
@Bindable var settings = viewModel.settings
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
|
HStack {
|
||||||
|
SybilWordmark(size: 34)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "shield.lefthalf.filled")
|
Image(systemName: "shield.lefthalf.filled")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.primary)
|
.foregroundStyle(SybilTheme.accent)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: 34, height: 34)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(SybilTheme.primary.opacity(0.18))
|
.fill(SybilTheme.accent.opacity(0.12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(SybilTheme.accent.opacity(0.28), lineWidth: 1)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Connect to Sybil")
|
Text("Connect to Sybil")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.sybil(.title3, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.")
|
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.")
|
||||||
.font(.callout)
|
.font(.sybil(.callout))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -32,25 +41,25 @@ struct SybilConnectionView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("API URL")
|
Text("API URL")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
|
TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text("Admin Token")
|
Text("Admin Token")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||||
@@ -59,11 +68,11 @@ struct SybilConnectionView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +103,7 @@ struct SybilConnectionView: View {
|
|||||||
|
|
||||||
if let authError = viewModel.authError {
|
if let authError = viewModel.authError {
|
||||||
Text(authError)
|
Text(authError)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -103,10 +112,10 @@ struct SybilConnectionView: View {
|
|||||||
.frame(maxWidth: 520)
|
.frame(maxWidth: 520)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 20)
|
RoundedRectangle(cornerRadius: 20)
|
||||||
.fill(SybilTheme.card)
|
.fill(SybilTheme.panelGradient)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 20)
|
RoundedRectangle(cornerRadius: 20)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.9), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
164
ios/Packages/Sybil/Sources/Sybil/SybilMarkdownTheme.swift
Normal file
164
ios/Packages/Sybil/Sources/Sybil/SybilMarkdownTheme.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension Theme {
|
||||||
|
static let sybilReadable: Theme = {
|
||||||
|
SybilFontRegistry.registerIfNeeded()
|
||||||
|
|
||||||
|
return Theme()
|
||||||
|
.text {
|
||||||
|
FontFamily(.custom("Inter"))
|
||||||
|
FontSize(15)
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
FontFamilyVariant(.monospaced)
|
||||||
|
FontSize(.em(0.88))
|
||||||
|
BackgroundColor(SybilTheme.surfaceStrong.opacity(0.78))
|
||||||
|
}
|
||||||
|
.strong {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
ForegroundColor(SybilTheme.accent)
|
||||||
|
}
|
||||||
|
.heading1 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(1.1), bottom: .em(0.5))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
FontSize(.em(1.45))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.heading2 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(1.0), bottom: .em(0.45))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
FontSize(.em(1.25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.heading3 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(0.9), bottom: .em(0.4))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
FontSize(.em(1.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.heading4 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(0.85), bottom: .em(0.35))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.heading5 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
FontSize(.em(0.92))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.heading6 { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.18))
|
||||||
|
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
FontSize(.em(0.86))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.paragraph { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.36))
|
||||||
|
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||||
|
}
|
||||||
|
.blockquote { configuration in
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(SybilTheme.primary.opacity(0.55))
|
||||||
|
.frame(width: 3)
|
||||||
|
configuration.label
|
||||||
|
.markdownTextStyle {
|
||||||
|
ForegroundColor(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.markdownMargin(top: .em(0.25), bottom: .em(0.9))
|
||||||
|
}
|
||||||
|
.codeBlock { configuration in
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.28))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontFamilyVariant(.monospaced)
|
||||||
|
FontSize(.em(0.88))
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(SybilTheme.surfaceStrong.opacity(0.82))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.markdownMargin(top: .em(0.2), bottom: .em(1))
|
||||||
|
}
|
||||||
|
.list { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.markdownMargin(top: .em(0.08), bottom: .em(0.78))
|
||||||
|
}
|
||||||
|
.listItem { configuration in
|
||||||
|
configuration.label
|
||||||
|
.markdownMargin(top: .em(0.28))
|
||||||
|
}
|
||||||
|
.table { configuration in
|
||||||
|
configuration.label
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.markdownTableBorderStyle(.init(color: SybilTheme.border.opacity(0.85)))
|
||||||
|
.markdownTableBackgroundStyle(
|
||||||
|
.alternatingRows(
|
||||||
|
SybilTheme.surface.opacity(0.72),
|
||||||
|
SybilTheme.surfaceStrong.opacity(0.62)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.markdownMargin(top: .em(0.2), bottom: .em(1))
|
||||||
|
}
|
||||||
|
.tableCell { configuration in
|
||||||
|
configuration.label
|
||||||
|
.markdownTextStyle {
|
||||||
|
if configuration.row == 0 {
|
||||||
|
FontWeight(.semibold)
|
||||||
|
}
|
||||||
|
BackgroundColor(nil)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.relativeLineSpacing(.em(0.30))
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.padding(.horizontal, 11)
|
||||||
|
}
|
||||||
|
.thematicBreak {
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border.opacity(0.82))
|
||||||
|
.markdownMargin(top: .em(1.2), bottom: .em(1.2))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -46,6 +46,115 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var role: MessageRole
|
public var role: MessageRole
|
||||||
public var content: String
|
public var content: String
|
||||||
public var name: String?
|
public var name: String?
|
||||||
|
public var metadata: JSONValue? = nil
|
||||||
|
|
||||||
|
public var toolCallMetadata: ToolCallMetadata? {
|
||||||
|
guard role == .tool,
|
||||||
|
let object = metadata?.objectValue,
|
||||||
|
object["kind"]?.stringValue == "tool_call"
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return ToolCallMetadata(
|
||||||
|
toolCallId: object["toolCallId"]?.stringValue,
|
||||||
|
toolName: object["toolName"]?.stringValue ?? name,
|
||||||
|
status: object["status"]?.stringValue,
|
||||||
|
summary: object["summary"]?.stringValue,
|
||||||
|
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||||
|
error: object["error"]?.stringValue,
|
||||||
|
resultPreview: object["resultPreview"]?.stringValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isToolCallLog: Bool {
|
||||||
|
toolCallMetadata != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ToolCallMetadata: Hashable, Sendable {
|
||||||
|
public var toolCallId: String?
|
||||||
|
public var toolName: String?
|
||||||
|
public var status: String?
|
||||||
|
public var summary: String?
|
||||||
|
public var durationMs: Int?
|
||||||
|
public var error: String?
|
||||||
|
public var resultPreview: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum JSONValue: Codable, Hashable, Sendable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case object([String: JSONValue])
|
||||||
|
case array([JSONValue])
|
||||||
|
case null
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
self = .null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let value = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let value = try? container.decode(Double.self) {
|
||||||
|
self = .number(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let value = try? container.decode(String.self) {
|
||||||
|
self = .string(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let value = try? container.decode([String: JSONValue].self) {
|
||||||
|
self = .object(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let value = try? container.decode([JSONValue].self) {
|
||||||
|
self = .array(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case let .string(value):
|
||||||
|
try container.encode(value)
|
||||||
|
case let .number(value):
|
||||||
|
try container.encode(value)
|
||||||
|
case let .bool(value):
|
||||||
|
try container.encode(value)
|
||||||
|
case let .object(value):
|
||||||
|
try container.encode(value)
|
||||||
|
case let .array(value):
|
||||||
|
try container.encode(value)
|
||||||
|
case .null:
|
||||||
|
try container.encodeNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var stringValue: String? {
|
||||||
|
if case let .string(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var numberValue: Double? {
|
||||||
|
if case let .number(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var objectValue: [String: JSONValue]? {
|
||||||
|
if case let .object(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
@@ -153,12 +262,26 @@ public struct CompletionStreamDone: Codable, Sendable {
|
|||||||
public var text: String
|
public var text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct CompletionStreamToolCall: Codable, Sendable {
|
||||||
|
public var toolCallId: String
|
||||||
|
public var name: String
|
||||||
|
public var status: String
|
||||||
|
public var summary: String
|
||||||
|
public var args: [String: JSONValue]
|
||||||
|
public var startedAt: String
|
||||||
|
public var completedAt: String
|
||||||
|
public var durationMs: Int
|
||||||
|
public var error: String?
|
||||||
|
public var resultPreview: String?
|
||||||
|
}
|
||||||
|
|
||||||
public struct StreamErrorPayload: Codable, Sendable {
|
public struct StreamErrorPayload: Codable, Sendable {
|
||||||
public var message: String
|
public var message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CompletionStreamEvent: Sendable {
|
public enum CompletionStreamEvent: Sendable {
|
||||||
case meta(CompletionStreamMeta)
|
case meta(CompletionStreamMeta)
|
||||||
|
case toolCall(CompletionStreamToolCall)
|
||||||
case delta(CompletionStreamDelta)
|
case delta(CompletionStreamDelta)
|
||||||
case done(CompletionStreamDone)
|
case done(CompletionStreamDone)
|
||||||
case error(StreamErrorPayload)
|
case error(StreamErrorPayload)
|
||||||
@@ -302,6 +425,14 @@ extension DateFormatter {
|
|||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
static let sybilShortTime: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = .autoupdatingCurrent
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
@@ -311,4 +442,8 @@ extension Date {
|
|||||||
formatter.unitsStyle = .abbreviated
|
formatter.unitsStyle = .abbreviated
|
||||||
return formatter.localizedString(for: self, relativeTo: Date())
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sybilShortTimeLabel: String {
|
||||||
|
DateFormatter.sybilShortTime.string(from: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ struct SybilPhoneShellView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||||
.navigationTitle("Sybil")
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
SybilWordmark(size: 19)
|
||||||
|
}
|
||||||
|
}
|
||||||
.navigationDestination(for: PhoneRoute.self) { route in
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
||||||
}
|
}
|
||||||
@@ -43,14 +48,9 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
buttonRow
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.overlay(SybilTheme.border)
|
|
||||||
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -65,7 +65,7 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
Text("Loading conversations…")
|
Text("Loading conversations…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
@@ -73,10 +73,10 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
} else if viewModel.sidebarItems.isEmpty {
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "message.badge")
|
Image(systemName: "message.badge")
|
||||||
.font(.title3)
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
Text("Start a chat or run your first search.")
|
Text("Start a chat or run your first search.")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
@@ -104,48 +104,67 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.background(SybilTheme.panelGradient)
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
bottomToolbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bottomToolbar: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
Divider()
|
Divider()
|
||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
NavigationLink(value: PhoneRoute.settings) {
|
HStack(spacing: 12) {
|
||||||
Label("Settings", systemImage: "gearshape")
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
.font(.subheadline.weight(.medium))
|
path = [.settings]
|
||||||
.foregroundStyle(SybilTheme.text)
|
}
|
||||||
.padding(.horizontal, 12)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
path = [.draftSearch]
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
path = [.draftChat]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.background(SybilTheme.panelGradient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolbarIconButton(
|
||||||
|
systemImage: String,
|
||||||
|
accessibilityLabel: String,
|
||||||
|
isPrimary: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
isPrimary
|
||||||
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
: AnyShapeStyle(SybilTheme.surface.opacity(0.78))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(10)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
}
|
|
||||||
.background(SybilTheme.surfaceStrong.opacity(0.84))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buttonRow: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button {
|
|
||||||
viewModel.startNewChat()
|
|
||||||
path.append(.draftChat)
|
|
||||||
} label: {
|
|
||||||
Label("New chat", systemImage: "plus")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(SybilTheme.primarySoft)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.startNewSearch()
|
|
||||||
path.append(.draftSearch)
|
|
||||||
} label: {
|
|
||||||
Label("New search", systemImage: "magnifyingglass")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,24 +175,36 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
.font(.caption2)
|
.font(.sybil(.caption2))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
if let initiated = item.initiatedLabel {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(initiated)
|
Text(initiated)
|
||||||
.font(.caption2)
|
.font(.sybil(.caption2))
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +214,7 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(SybilTheme.surface.opacity(0.55))
|
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ struct SybilSearchResultsView: View {
|
|||||||
if let query = search?.query, !query.isEmpty {
|
if let query = search?.query, !query.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Results for")
|
Text("Results for")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
Text(query)
|
Text(query)
|
||||||
.font(.title3.weight(.semibold))
|
.font(.sybil(.title3, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
Text(resultCountLabel)
|
Text(resultCountLabel)
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,14 +36,14 @@ struct SybilSearchResultsView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.tint(SybilTheme.textMuted)
|
.tint(SybilTheme.textMuted)
|
||||||
Text(isRunning ? "Searching Exa…" : "Loading search…")
|
Text(isRunning ? "Searching Exa…" : "Loading search…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
|
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
|
||||||
Text("No results found.")
|
Text("No results found.")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ struct SybilSearchResultsView: View {
|
|||||||
|
|
||||||
if let error = search?.error, !error.isEmpty {
|
if let error = search?.error, !error.isEmpty {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,9 +80,9 @@ struct SybilSearchResultsView: View {
|
|||||||
private var answerCard: some View {
|
private var answerCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Answer")
|
Text("Answer")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.foregroundStyle(SybilTheme.primary)
|
.foregroundStyle(SybilTheme.primary.opacity(0.92))
|
||||||
|
|
||||||
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
|
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -90,21 +90,19 @@ struct SybilSearchResultsView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.tint(SybilTheme.textMuted)
|
.tint(SybilTheme.textMuted)
|
||||||
Text("Generating answer…")
|
Text("Generating answer…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
} else if let answer = search?.answerText, !answer.isEmpty {
|
} else if let answer = search?.answerText, !answer.isEmpty {
|
||||||
Markdown(answer)
|
Markdown(answer)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.markdownTextStyle {
|
.markdownTheme(.sybilReadable)
|
||||||
FontSize(15)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let answerError = search?.answerError, !answerError.isEmpty {
|
if let answerError = search?.answerError, !answerError.isEmpty {
|
||||||
Text(answerError)
|
Text(answerError)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +113,10 @@ struct SybilSearchResultsView: View {
|
|||||||
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
|
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("[\(index + 1)]")
|
Text("[\(index + 1)]")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.sybil(.caption2, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.primary)
|
.foregroundStyle(SybilTheme.primary)
|
||||||
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
|
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
}
|
}
|
||||||
@@ -126,10 +124,10 @@ struct SybilSearchResultsView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.primary.opacity(0.10))
|
||||||
.overlay(
|
.overlay(
|
||||||
Capsule()
|
Capsule()
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,10 +140,10 @@ struct SybilSearchResultsView: View {
|
|||||||
.padding(14)
|
.padding(14)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(SybilTheme.searchCard)
|
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -171,45 +169,49 @@ private struct SearchResultCard: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
Text(host)
|
Text(host)
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.foregroundStyle(SybilTheme.primary.opacity(0.9))
|
.foregroundStyle(SybilTheme.accent.opacity(0.9))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let resolvedURL {
|
if let resolvedURL {
|
||||||
Link(destination: resolvedURL) {
|
Link(destination: resolvedURL) {
|
||||||
Text(result.title.orEmpty.orFallback(result.url))
|
Text(result.title.orEmpty.orFallback(result.url))
|
||||||
.font(.headline)
|
.font(.sybil(.headline))
|
||||||
.foregroundStyle(SybilTheme.primary)
|
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
||||||
|
.lineSpacing(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
Text(result.title.orEmpty.orFallback(result.url))
|
Text(result.title.orEmpty.orFallback(result.url))
|
||||||
.font(.headline)
|
.font(.sybil(.headline))
|
||||||
.foregroundStyle(SybilTheme.primary)
|
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
||||||
|
.lineSpacing(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let date = result.publishedDate, !date.isEmpty {
|
if let date = result.publishedDate, !date.isEmpty {
|
||||||
Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)"))
|
Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)"))
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
} else if let author = result.author, !author.isEmpty {
|
} else if let author = result.author, !author.isEmpty {
|
||||||
Text(author)
|
Text(author)
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(result.url)
|
Text(result.url)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.text.opacity(0.92))
|
.foregroundStyle(SybilTheme.text.opacity(0.92))
|
||||||
|
.lineSpacing(2)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|
||||||
if let highlights = result.highlights, !highlights.isEmpty {
|
if let highlights = result.highlights, !highlights.isEmpty {
|
||||||
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
|
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
|
||||||
Text("• \(highlight)")
|
Text("• \(highlight)")
|
||||||
.font(.caption)
|
.font(.sybil(.caption))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineSpacing(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,10 +222,10 @@ private struct SearchResultCard: View {
|
|||||||
.padding(14)
|
.padding(14)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 14)
|
RoundedRectangle(cornerRadius: 14)
|
||||||
.fill(SybilTheme.searchCard.opacity(0.95))
|
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.96), SybilTheme.surface.opacity(0.72)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 14)
|
RoundedRectangle(cornerRadius: 14)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ final class SybilSettingsStore {
|
|||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
|
||||||
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let fallbackBaseURL = "http://127.0.0.1:8787/api"
|
let fallbackBaseURL = "http://127.0.0.1:8787"
|
||||||
self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
|
self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
|
||||||
|
|
||||||
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
|
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
|
||||||
@@ -64,10 +64,19 @@ final class SybilSettingsStore {
|
|||||||
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !raw.isEmpty else { return nil }
|
guard !raw.isEmpty else { return nil }
|
||||||
|
|
||||||
if raw.hasSuffix("/") {
|
while raw.hasSuffix("/") {
|
||||||
raw.removeLast()
|
raw.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
return URL(string: raw)
|
guard var components = URLComponents(string: raw) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
if path.lowercased() == "api" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return components.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ struct SybilSettingsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Connection")
|
Text("Connection")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.sybil(.title3, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`")
|
Text("Use the API server root. Example: `http://127.0.0.1:8787`")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("API URL")
|
Text("API URL")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
|
TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
@@ -38,7 +38,7 @@ struct SybilSettingsView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text("Admin Token")
|
Text("Admin Token")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||||
@@ -82,19 +82,19 @@ struct SybilSettingsView: View {
|
|||||||
|
|
||||||
if let mode = viewModel.authMode {
|
if let mode = viewModel.authMode {
|
||||||
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right")
|
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let authError = viewModel.authError {
|
if let authError = viewModel.authError {
|
||||||
Text(authError)
|
Text(authError)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let runtimeError = viewModel.errorMessage {
|
if let runtimeError = viewModel.errorMessage {
|
||||||
Text(runtimeError)
|
Text(runtimeError)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,27 +17,31 @@ struct SybilSidebarView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 10) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Button {
|
SybilWordmark(size: 31)
|
||||||
viewModel.startNewChat()
|
|
||||||
} label: {
|
|
||||||
Label("New chat", systemImage: "plus")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft)
|
|
||||||
|
|
||||||
Button {
|
VStack(spacing: 10) {
|
||||||
viewModel.startNewSearch()
|
sidebarActionButton(
|
||||||
} label: {
|
title: "New chat",
|
||||||
Label("New search", systemImage: "magnifyingglass")
|
systemImage: "plus",
|
||||||
.frame(maxWidth: .infinity)
|
isPrimary: true,
|
||||||
|
isActive: viewModel.draftKind == .chat
|
||||||
|
) {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarActionButton(
|
||||||
|
title: "New search",
|
||||||
|
systemImage: "magnifyingglass",
|
||||||
|
isPrimary: false,
|
||||||
|
isActive: viewModel.draftKind == .search
|
||||||
|
) {
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 12)
|
.padding(.top, 18)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -45,7 +49,7 @@ struct SybilSidebarView: View {
|
|||||||
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -60,7 +64,7 @@ struct SybilSidebarView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
Text("Loading conversations…")
|
Text("Loading conversations…")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
@@ -68,10 +72,10 @@ struct SybilSidebarView: View {
|
|||||||
} else if viewModel.sidebarItems.isEmpty {
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "message.badge")
|
Image(systemName: "message.badge")
|
||||||
.font(.title3)
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
Text("Start a chat or run your first search.")
|
Text("Start a chat or run your first search.")
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
@@ -87,24 +91,36 @@ struct SybilSidebarView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: iconName(for: item))
|
Image(systemName: iconName(for: item))
|
||||||
.font(.caption.weight(.semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
.font(.caption2)
|
.font(.sybil(.caption2))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
if let initiated = item.initiatedLabel {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(initiated)
|
Text(initiated)
|
||||||
.font(.caption2)
|
.font(.sybil(.caption2))
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +130,7 @@ struct SybilSidebarView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(isSelected(item) ? SybilTheme.primary.opacity(0.28) : SybilTheme.surface.opacity(0.4))
|
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
@@ -144,7 +160,7 @@ struct SybilSidebarView: View {
|
|||||||
viewModel.openSettings()
|
viewModel.openSettings()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Settings", systemImage: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.sybil(.subheadline, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -157,6 +173,32 @@ struct SybilSidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.background(SybilTheme.surfaceStrong.opacity(0.84))
|
.background(SybilTheme.panelGradient)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarActionButton(
|
||||||
|
title: String,
|
||||||
|
systemImage: String,
|
||||||
|
isPrimary: Bool,
|
||||||
|
isActive: Bool,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.text.opacity(0.92))
|
||||||
|
.padding(.horizontal, 13)
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 11)
|
||||||
|
.fill(isPrimary ? SybilTheme.primaryGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surface.opacity(0.62)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 11)
|
||||||
|
.stroke(isActive ? SybilTheme.primary.opacity(0.70) : SybilTheme.border.opacity(isPrimary ? 0.28 : 0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,194 @@
|
|||||||
|
import CoreText
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SybilFontRegistry {
|
||||||
|
static func registerIfNeeded() {
|
||||||
|
_ = registeredFonts
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let registeredFonts: Void = {
|
||||||
|
for fontName in ["Inter", "Orbitron"] {
|
||||||
|
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||||
|
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Font {
|
||||||
|
static func sybil(_ textStyle: Font.TextStyle, weight: Font.Weight = .regular) -> Font {
|
||||||
|
SybilFontRegistry.registerIfNeeded()
|
||||||
|
return .custom("Inter", size: Self.sybilPointSize(for: textStyle), relativeTo: textStyle)
|
||||||
|
.weight(weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sybil(size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||||
|
SybilFontRegistry.registerIfNeeded()
|
||||||
|
return .custom("Inter", size: size)
|
||||||
|
.weight(weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sybilPointSize(for textStyle: Font.TextStyle) -> CGFloat {
|
||||||
|
switch textStyle {
|
||||||
|
case .largeTitle:
|
||||||
|
return 34
|
||||||
|
case .title:
|
||||||
|
return 28
|
||||||
|
case .title2:
|
||||||
|
return 22
|
||||||
|
case .title3:
|
||||||
|
return 20
|
||||||
|
case .headline:
|
||||||
|
return 17
|
||||||
|
case .subheadline:
|
||||||
|
return 15
|
||||||
|
case .callout:
|
||||||
|
return 16
|
||||||
|
case .caption:
|
||||||
|
return 12
|
||||||
|
case .caption2:
|
||||||
|
return 11
|
||||||
|
case .footnote:
|
||||||
|
return 13
|
||||||
|
case .body:
|
||||||
|
return 17
|
||||||
|
@unknown default:
|
||||||
|
return 17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum SybilTheme {
|
enum SybilTheme {
|
||||||
static let background = Color(red: 0.07, green: 0.05, blue: 0.11)
|
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||||
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19)
|
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||||
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23)
|
static let surfaceStrong = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||||
static let card = Color(red: 0.12, green: 0.08, blue: 0.17)
|
static let card = Color(red: 0.06, green: 0.05, blue: 0.12)
|
||||||
static let border = Color(red: 0.30, green: 0.22, blue: 0.39)
|
static let border = Color(red: 0.24, green: 0.20, blue: 0.38)
|
||||||
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94)
|
static let primary = Color(red: 0.57, green: 0.38, blue: 0.96)
|
||||||
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78)
|
static let primarySoft = Color(red: 0.43, green: 0.25, blue: 0.76)
|
||||||
static let text = Color(red: 0.95, green: 0.91, blue: 0.98)
|
static let accent = Color(red: 0.31, green: 0.88, blue: 0.95)
|
||||||
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80)
|
static let text = Color(red: 0.96, green: 0.94, blue: 1.0)
|
||||||
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22)
|
static let textMuted = Color(red: 0.65, green: 0.62, blue: 0.76)
|
||||||
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62)
|
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||||
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42)
|
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 var backgroundGradient: LinearGradient {
|
static var backgroundGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.27, green: 0.12, blue: 0.36),
|
Color(red: 0.12, green: 0.08, blue: 0.28),
|
||||||
Color(red: 0.15, green: 0.09, blue: 0.23),
|
Color(red: 0.04, green: 0.03, blue: 0.09),
|
||||||
Color(red: 0.07, green: 0.05, blue: 0.11)
|
background
|
||||||
],
|
],
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var brandGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 1.0, green: 0.55, blue: 0.97),
|
||||||
|
Color(red: 0.60, green: 0.43, blue: 1.0),
|
||||||
|
Color(red: 0.40, green: 0.87, blue: 1.0)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var panelGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.07, green: 0.06, blue: 0.15).opacity(0.94),
|
||||||
|
Color(red: 0.03, green: 0.03, blue: 0.08).opacity(0.96)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var primaryGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.43, green: 0.27, blue: 0.92),
|
||||||
|
Color(red: 0.45, green: 0.09, blue: 0.63)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var selectedRowGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
primary.opacity(0.56),
|
||||||
|
primarySoft.opacity(0.48)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var userBubbleGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.38, green: 0.16, blue: 0.80),
|
||||||
|
Color(red: 0.25, green: 0.08, blue: 0.55)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var composerGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.04, green: 0.04, blue: 0.10).opacity(0.98),
|
||||||
|
Color(red: 0.09, green: 0.06, blue: 0.16).opacity(0.94)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var toolCallGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
|
||||||
|
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var failedToolCallGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
danger.opacity(0.18),
|
||||||
|
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilWordmark: View {
|
||||||
|
var size: CGFloat = 30
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("SYBIL")
|
||||||
|
.font(.custom("Orbitron", size: size))
|
||||||
|
.fontWeight(.black)
|
||||||
|
.tracking(0)
|
||||||
|
.foregroundStyle(SybilTheme.brandGradient)
|
||||||
|
.accessibilityLabel("Sybil")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,15 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var providerModelOptions: [String] {
|
var providerModelOptions: [String] {
|
||||||
let serverModels = modelCatalog[provider]?.models ?? []
|
modelOptions(for: provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelOptions(for candidate: Provider) -> [String] {
|
||||||
|
let serverModels = modelCatalog[candidate]?.models ?? []
|
||||||
if !serverModels.isEmpty {
|
if !serverModels.isEmpty {
|
||||||
return serverModels
|
return serverModels
|
||||||
}
|
}
|
||||||
return fallbackModels[provider] ?? []
|
return fallbackModels[candidate] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedTitle: String {
|
var selectedTitle: String {
|
||||||
@@ -222,7 +226,7 @@ final class SybilViewModel {
|
|||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||||
if let provider = chat.initiatedProvider {
|
if let provider = chat.initiatedProvider {
|
||||||
initiatedLabel = "\(provider.displayName) · \(model)"
|
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||||
} else {
|
} else {
|
||||||
initiatedLabel = model
|
initiatedLabel = model
|
||||||
}
|
}
|
||||||
@@ -336,6 +340,15 @@ final class SybilViewModel {
|
|||||||
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
|
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setProvider(_ nextProvider: Provider, model nextModel: String) {
|
||||||
|
provider = nextProvider
|
||||||
|
model = nextModel
|
||||||
|
settings.preferredProvider = nextProvider
|
||||||
|
settings.preferredModelByProvider[nextProvider] = nextModel
|
||||||
|
settings.persist()
|
||||||
|
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
|
||||||
|
}
|
||||||
|
|
||||||
func startNewChat() {
|
func startNewChat() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||||
draftKind = .chat
|
draftKind = .chat
|
||||||
@@ -693,7 +706,9 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let requestMessages: [CompletionRequestMessage] =
|
let requestMessages: [CompletionRequestMessage] =
|
||||||
baseChat.messages.map {
|
baseChat.messages
|
||||||
|
.filter { !$0.isToolCallLog }
|
||||||
|
.map {
|
||||||
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
||||||
} + [CompletionRequestMessage(role: .user, content: content)]
|
} + [CompletionRequestMessage(role: .user, content: content)]
|
||||||
|
|
||||||
@@ -749,6 +764,9 @@ final class SybilViewModel {
|
|||||||
case let .meta(payload):
|
case let .meta(payload):
|
||||||
pendingChatState?.chatID = payload.chatId
|
pendingChatState?.chatID = payload.chatId
|
||||||
|
|
||||||
|
case let .toolCall(payload):
|
||||||
|
insertPendingToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
mutatePendingAssistantMessage { existing in
|
mutatePendingAssistantMessage { existing in
|
||||||
@@ -880,6 +898,51 @@ final class SybilViewModel {
|
|||||||
pendingChatState = pending
|
pendingChatState = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||||
|
guard var pending = pendingChatState else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata: JSONValue = .object([
|
||||||
|
"kind": .string("tool_call"),
|
||||||
|
"toolCallId": .string(payload.toolCallId),
|
||||||
|
"toolName": .string(payload.name),
|
||||||
|
"status": .string(payload.status),
|
||||||
|
"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
|
||||||
|
])
|
||||||
|
|
||||||
|
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? "Ran tool '\(payload.name)'."
|
||||||
|
: payload.summary
|
||||||
|
|
||||||
|
let message = Message(
|
||||||
|
id: "temp-tool-\(payload.toolCallId)",
|
||||||
|
createdAt: Date(),
|
||||||
|
role: .tool,
|
||||||
|
content: summary,
|
||||||
|
name: payload.name,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
|
||||||
|
pending.messages.insert(message, at: assistantIndex)
|
||||||
|
} else {
|
||||||
|
pending.messages.append(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingChatState = pending
|
||||||
|
}
|
||||||
|
|
||||||
private var currentChatID: String? {
|
private var currentChatID: String? {
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
|
|
||||||
|
private var isCompact: Bool {
|
||||||
|
horizontalSizeClass == .compact
|
||||||
|
}
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
return true
|
return true
|
||||||
@@ -12,12 +17,18 @@ struct SybilWorkspaceView: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var showsHeader: Bool {
|
||||||
|
!isCompact || viewModel.errorMessage != nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if showsHeader {
|
||||||
header
|
header
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
|
}
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if isSettingsSelected {
|
if isSettingsSelected {
|
||||||
@@ -44,18 +55,30 @@ struct SybilWorkspaceView: View {
|
|||||||
composerBar
|
composerBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(viewModel.selectedTitle)
|
.navigationTitle(isCompact ? "" : viewModel.selectedTitle)
|
||||||
|
.toolbar {
|
||||||
|
if isCompact {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Text(viewModel.selectedTitle)
|
||||||
|
.font(.sybil(.headline, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCompact && !viewModel.isSearchMode && !isSettingsSelected {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
compactProviderModelMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.onChange(of: viewModel.isSending) { _, isSending in
|
|
||||||
if !isSending, viewModel.showsComposer {
|
|
||||||
composerFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if !isCompact {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -63,30 +86,76 @@ struct SybilWorkspaceView: View {
|
|||||||
providerControls
|
providerControls
|
||||||
} else if viewModel.isSearchMode {
|
} else if viewModel.isSearchMode {
|
||||||
Label("Search mode", systemImage: "globe")
|
Label("Search mode", systemImage: "globe")
|
||||||
.font(.caption.weight(.medium))
|
.font(.sybil(.caption, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.accent)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.accent.opacity(0.10))
|
||||||
.overlay(
|
.overlay(
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
if let error = viewModel.errorMessage {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.footnote)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactProviderModelMenu: some View {
|
||||||
|
Menu {
|
||||||
|
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||||
|
Menu(candidate.displayName) {
|
||||||
|
let models = viewModel.modelOptions(for: candidate)
|
||||||
|
if models.isEmpty {
|
||||||
|
Text("No models")
|
||||||
|
} else {
|
||||||
|
ForEach(models, id: \.self) { candidateModel in
|
||||||
|
Button {
|
||||||
|
viewModel.setProvider(candidate, model: candidateModel)
|
||||||
|
} label: {
|
||||||
|
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||||
|
Label(candidateModel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(candidateModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Provider and model")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providerControls: some View {
|
private var providerControls: some View {
|
||||||
@@ -100,16 +169,16 @@ struct SybilWorkspaceView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(viewModel.provider.displayName, systemImage: "chevron.down")
|
Label(viewModel.provider.displayName, systemImage: "chevron.down")
|
||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
.font(.caption.weight(.medium))
|
.font(.sybil(.caption, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -123,17 +192,17 @@ struct SybilWorkspaceView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(viewModel.model, systemImage: "chevron.down")
|
Label(viewModel.model, systemImage: "chevron.down")
|
||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
.font(.caption.weight(.medium))
|
.font(.sybil(.caption, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,10 +230,10 @@ struct SybilWorkspaceView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(SybilTheme.surface)
|
.fill(SybilTheme.composerGradient)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(SybilTheme.border, lineWidth: 1)
|
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
@@ -175,11 +244,15 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.surface : SybilTheme.primary)
|
.fill(
|
||||||
|
viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending
|
||||||
|
? AnyShapeStyle(SybilTheme.surface)
|
||||||
|
: AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||||
}
|
}
|
||||||
@@ -188,6 +261,15 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(SybilTheme.background)
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
SybilTheme.background.opacity(0.18),
|
||||||
|
SybilTheme.background.opacity(0.96)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
server/package-lock.json
generated
168
server/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exa-js": "^2.4.0",
|
"exa-js": "^2.4.0",
|
||||||
"fastify": "^5.7.2",
|
"fastify": "^5.7.2",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prisma": "^6.6.0",
|
"prisma": "^6.6.0",
|
||||||
@@ -852,6 +853,19 @@
|
|||||||
"@prisma/debug": "6.6.0"
|
"@prisma/debug": "6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.0.10",
|
"version": "25.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
||||||
@@ -996,6 +1010,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -1014,6 +1037,61 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -1035,6 +1113,18 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -1353,6 +1443,41 @@
|
|||||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^8.0.2",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -1452,6 +1577,15 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -1648,6 +1782,19 @@
|
|||||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||||
|
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.6.0",
|
||||||
|
"peberminta": "^0.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
@@ -1664,6 +1811,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||||
@@ -1882,6 +2038,18 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "^0.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exa-js": "^2.4.0",
|
"exa-js": "^2.4.0",
|
||||||
"fastify": "^5.7.2",
|
"fastify": "^5.7.2",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prisma": "^6.6.0",
|
"prisma": "^6.6.0",
|
||||||
|
|||||||
657
server/src/llm/chat-tools.ts
Normal file
657
server/src/llm/chat-tools.ts
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import { convert as htmlToText } from "html-to-text";
|
||||||
|
import type OpenAI from "openai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { exaClient } from "../search/exa.js";
|
||||||
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
|
const MAX_TOOL_ROUNDS = 4;
|
||||||
|
const DEFAULT_WEB_RESULTS = 5;
|
||||||
|
const MAX_WEB_RESULTS = 10;
|
||||||
|
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
||||||
|
const MAX_FETCH_MAX_CHARACTERS = 50_000;
|
||||||
|
const FETCH_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
const WebSearchArgsSchema = z
|
||||||
|
.object({
|
||||||
|
query: z.string().trim().min(1),
|
||||||
|
numResults: z.coerce.number().int().min(1).max(MAX_WEB_RESULTS).optional(),
|
||||||
|
type: z.enum(["auto", "fast", "instant"]).optional(),
|
||||||
|
includeDomains: z.array(z.string().trim().min(1)).max(25).optional(),
|
||||||
|
excludeDomains: z.array(z.string().trim().min(1)).max(25).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const FetchUrlArgsSchema = z
|
||||||
|
.object({
|
||||||
|
url: z.string().trim().url(),
|
||||||
|
maxCharacters: z.coerce.number().int().min(500).max(MAX_FETCH_MAX_CHARACTERS).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const CHAT_TOOLS: any[] = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "web_search",
|
||||||
|
description:
|
||||||
|
"Search the public web for recent or factual information. Returns ranked results with per-result summaries and snippets.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search query." },
|
||||||
|
numResults: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MAX_WEB_RESULTS,
|
||||||
|
description: "Number of results to return (default 5).",
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["auto", "fast", "instant"],
|
||||||
|
description: "Search mode.",
|
||||||
|
},
|
||||||
|
includeDomains: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Only include these domains.",
|
||||||
|
},
|
||||||
|
excludeDomains: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Exclude these domains.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "fetch_url",
|
||||||
|
description:
|
||||||
|
"Fetch a webpage by URL and return readable plaintext content extracted from the page for deeper inspection.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "Absolute URL to fetch, including http/https." },
|
||||||
|
maxCharacters: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 500,
|
||||||
|
maximum: MAX_FETCH_MAX_CHARACTERS,
|
||||||
|
description: "Maximum response text characters returned (default 12000).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||||
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
|
"Use web_search for discovery and recent facts, and 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. " +
|
||||||
|
"Do not fabricate tool outputs; reason only from provided tool results.";
|
||||||
|
|
||||||
|
type ToolRunOutcome = {
|
||||||
|
ok: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolAwareUsage = {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolAwareCompletionResult = {
|
||||||
|
text: string;
|
||||||
|
usage?: ToolAwareUsage;
|
||||||
|
raw: unknown;
|
||||||
|
toolEvents: ToolExecutionEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolAwareStreamingEvent =
|
||||||
|
| { type: "delta"; text: string }
|
||||||
|
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||||
|
| { type: "done"; result: ToolAwareCompletionResult };
|
||||||
|
|
||||||
|
type ToolAwareCompletionParams = {
|
||||||
|
client: OpenAI;
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||||
|
logContext?: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
chatId?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolExecutionEvent = {
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
summary: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
resultPreview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function compactWhitespace(input: string) {
|
||||||
|
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipText(input: string, maxCharacters: number) {
|
||||||
|
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return { ...(value as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingleLine(value: string, maxLength = 220) {
|
||||||
|
return clipText(
|
||||||
|
value
|
||||||
|
.replace(/\r?\n+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim(),
|
||||||
|
maxLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
||||||
|
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
||||||
|
if (name === "web_search") {
|
||||||
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
||||||
|
if (status === "completed") {
|
||||||
|
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
||||||
|
}
|
||||||
|
return query ? `Web search for '${toSingleLine(query, 100)}' failed.${errSuffix}` : `Web search failed.${errSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "fetch_url") {
|
||||||
|
const url = typeof args.url === "string" ? args.url.trim() : "";
|
||||||
|
if (status === "completed") {
|
||||||
|
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
||||||
|
}
|
||||||
|
return url ? `Fetching URL ${toSingleLine(url, 140)} failed.${errSuffix}` : `Fetching URL failed.${errSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
return `Ran tool '${name}'.`;
|
||||||
|
}
|
||||||
|
return `Tool '${name}' failed.${errSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logToolEvent(event: ToolExecutionEvent, context?: ToolAwareCompletionParams["logContext"]) {
|
||||||
|
const payload = {
|
||||||
|
kind: "tool_call",
|
||||||
|
...context,
|
||||||
|
...event,
|
||||||
|
};
|
||||||
|
const line = `[tool_call] ${JSON.stringify(payload)}`;
|
||||||
|
if (event.status === "failed") console.error(line);
|
||||||
|
else console.info(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultPreview(toolResult: ToolRunOutcome) {
|
||||||
|
const serialized = JSON.stringify(toolResult);
|
||||||
|
return serialized ? clipText(serialized, 400) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolLogMessageData(chatId: string, event: ToolExecutionEvent) {
|
||||||
|
return {
|
||||||
|
chatId,
|
||||||
|
role: "tool" as const,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHtmlTitle(html: string) {
|
||||||
|
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
return compactWhitespace(
|
||||||
|
match[1]
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||||
|
const normalized = messages.map((m) => {
|
||||||
|
if (m.role === "tool") {
|
||||||
|
const name = m.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${m.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (m.role === "assistant" || m.role === "system" || m.role === "user") {
|
||||||
|
const out: any = { role: m.role, content: m.content };
|
||||||
|
if (m.name && (m.role === "assistant" || m.role === "user")) {
|
||||||
|
out.name = m.name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return { role: "user", content: m.content };
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
|
const args = WebSearchArgsSchema.parse(input);
|
||||||
|
const exa = exaClient();
|
||||||
|
const response = await exa.search(args.query, {
|
||||||
|
type: args.type ?? "auto",
|
||||||
|
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
|
||||||
|
includeDomains: args.includeDomains,
|
||||||
|
excludeDomains: args.excludeDomains,
|
||||||
|
moderation: true,
|
||||||
|
userLocation: "US",
|
||||||
|
contents: {
|
||||||
|
summary: { query: args.query },
|
||||||
|
highlights: {
|
||||||
|
query: args.query,
|
||||||
|
maxCharacters: 320,
|
||||||
|
numSentences: 2,
|
||||||
|
highlightsPerUrl: 2,
|
||||||
|
},
|
||||||
|
text: { maxCharacters: 1_000 },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const results = Array.isArray(response?.results) ? response.results : [];
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
query: args.query,
|
||||||
|
requestId: response?.requestId ?? null,
|
||||||
|
results: results.map((result: any, index: number) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
title: typeof result?.title === "string" ? result.title : null,
|
||||||
|
url: typeof result?.url === "string" ? result.url : null,
|
||||||
|
publishedDate: typeof result?.publishedDate === "string" ? result.publishedDate : null,
|
||||||
|
author: typeof result?.author === "string" ? result.author : null,
|
||||||
|
summary: typeof result?.summary === "string" ? clipText(result.summary, 1_400) : null,
|
||||||
|
text: typeof result?.text === "string" ? clipText(result.text, 700) : null,
|
||||||
|
highlights: Array.isArray(result?.highlights)
|
||||||
|
? result.highlights.filter((h: unknown) => typeof h === "string").slice(0, 3).map((h: string) => clipText(h, 280))
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeFetchUrl(urlRaw: string) {
|
||||||
|
const parsed = new URL(urlRaw);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Only http:// and https:// URLs are supported.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
|
||||||
|
const args = FetchUrlArgsSchema.parse(input);
|
||||||
|
const parsed = assertSafeFetchUrl(args.url);
|
||||||
|
const maxCharacters = args.maxCharacters ?? DEFAULT_FETCH_MAX_CHARACTERS;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||||
|
const body = await response.text();
|
||||||
|
const isHtml = contentType.includes("text/html") || /<!doctype html|<html[\s>]/i.test(body);
|
||||||
|
|
||||||
|
let extracted = body;
|
||||||
|
if (isHtml) {
|
||||||
|
extracted = htmlToText(body, {
|
||||||
|
wordwrap: false,
|
||||||
|
preserveNewlines: true,
|
||||||
|
selectors: [
|
||||||
|
{ selector: "img", format: "skip" },
|
||||||
|
{ selector: "script", format: "skip" },
|
||||||
|
{ selector: "style", format: "skip" },
|
||||||
|
{ selector: "noscript", format: "skip" },
|
||||||
|
{ selector: "a", options: { ignoreHref: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = compactWhitespace(extracted);
|
||||||
|
const truncated = normalized.length > maxCharacters;
|
||||||
|
const text = truncated
|
||||||
|
? `${normalized.slice(0, maxCharacters)}\n\n[truncated ${normalized.length - maxCharacters} characters]`
|
||||||
|
: normalized;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url: response.url || parsed.toString(),
|
||||||
|
status: response.status,
|
||||||
|
contentType: contentType || null,
|
||||||
|
title: isHtml ? extractHtmlTitle(body) : null,
|
||||||
|
truncated,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome> {
|
||||||
|
if (name === "web_search") return runWebSearchTool(args);
|
||||||
|
if (name === "fetch_url") return runFetchUrlTool(args);
|
||||||
|
return { ok: false, error: `Unknown tool: ${name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolArgs(raw: unknown) {
|
||||||
|
if (typeof raw !== "string") return {};
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Invalid JSON arguments: ${err?.message ?? String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
if (!usage) return false;
|
||||||
|
acc.inputTokens += usage.prompt_tokens ?? 0;
|
||||||
|
acc.outputTokens += usage.completion_tokens ?? 0;
|
||||||
|
acc.totalTokens += usage.total_tokens ?? 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalizedToolCall = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return toolCalls.map((call: any, index: number) => ({
|
||||||
|
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call?.function?.name ?? "unknown_tool",
|
||||||
|
arguments: call?.function?.arguments ?? "{}",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolCallAndBuildEvent(
|
||||||
|
call: NormalizedToolCall,
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||||
|
const startedAtMs = Date.now();
|
||||||
|
const startedAt = new Date(startedAtMs).toISOString();
|
||||||
|
let toolResult: ToolRunOutcome;
|
||||||
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||||
|
toolResult = await executeTool(call.name, parsedArgs);
|
||||||
|
} catch (err: any) {
|
||||||
|
toolResult = {
|
||||||
|
ok: false,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: "completed" | "failed" = toolResult.ok ? "completed" : "failed";
|
||||||
|
const error =
|
||||||
|
status === "failed"
|
||||||
|
? typeof toolResult.error === "string"
|
||||||
|
? toolResult.error
|
||||||
|
: "Tool execution failed."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const completedAtMs = Date.now();
|
||||||
|
const event: ToolExecutionEvent = {
|
||||||
|
toolCallId: call.id,
|
||||||
|
name: call.name,
|
||||||
|
status,
|
||||||
|
summary: buildToolSummary(call.name, parsedArgs, status, error),
|
||||||
|
args: parsedArgs,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date(completedAtMs).toISOString(),
|
||||||
|
durationMs: completedAtMs - startedAtMs,
|
||||||
|
error,
|
||||||
|
resultPreview: buildResultPreview(toolResult),
|
||||||
|
};
|
||||||
|
logToolEvent(event, params.logContext);
|
||||||
|
if (params.onToolEvent) {
|
||||||
|
await params.onToolEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { event, toolResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: CHAT_TOOLS,
|
||||||
|
tool_choice: "auto",
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(completion);
|
||||||
|
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
|
if (!toolCalls.length) {
|
||||||
|
return {
|
||||||
|
text: typeof message.content === "string" ? message.content : "",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
|
||||||
|
const assistantToolCallMessage: any = {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (typeof message.content === "string" && message.content.length) {
|
||||||
|
assistantToolCallMessage.content = message.content;
|
||||||
|
}
|
||||||
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* runToolAwareOpenAIChatStream(
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let totalToolCalls = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||||
|
const stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: CHAT_TOOLS,
|
||||||
|
tool_choice: "auto",
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
let roundText = "";
|
||||||
|
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const choice = chunk?.choices?.[0];
|
||||||
|
const deltaText = choice?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
roundText += deltaText;
|
||||||
|
if (roundToolCalls.size === 0) {
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||||
|
for (const toolCall of deltaToolCalls) {
|
||||||
|
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||||
|
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||||
|
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||||
|
entry.id = toolCall.id;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||||
|
entry.name = toolCall.function.name;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||||
|
entry.arguments += toolCall.function.arguments;
|
||||||
|
}
|
||||||
|
roundToolCalls.set(idx, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([_, call], index) => ({
|
||||||
|
id: call.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call.name ?? "unknown_tool",
|
||||||
|
arguments: call.arguments || "{}",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
yield { type: "tool_call", event };
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
|
import { buildToolLogMessageData, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
|
|
||||||
function asProviderEnum(p: Provider) {
|
function asProviderEnum(p: Provider) {
|
||||||
@@ -44,25 +45,26 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
let outText = "";
|
let outText = "";
|
||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
|
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
if (req.provider === "openai" || req.provider === "xai") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
||||||
const r = await client.chat.completions.create({
|
const r = await runToolAwareOpenAIChat({
|
||||||
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
// OpenAI SDK has very specific message union types; our normalized schema is compatible.
|
messages: req.messages,
|
||||||
messages: req.messages.map((m) => ({ role: m.role, content: m.content, name: m.name })) as any,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_tokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
|
logContext: {
|
||||||
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
|
chatId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
raw = r;
|
raw = r.raw;
|
||||||
outText = r.choices?.[0]?.message?.content ?? "";
|
outText = r.text;
|
||||||
usage = r.usage
|
usage = r.usage;
|
||||||
? {
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
inputTokens: r.usage.prompt_tokens,
|
|
||||||
outputTokens: r.usage.completion_tokens,
|
|
||||||
totalTokens: r.usage.total_tokens,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
@@ -100,16 +102,27 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
// Store assistant message + call record
|
// Store tool activity (if any), assistant message, and call record.
|
||||||
await prisma.$transaction([
|
await prisma.$transaction(async (tx) => {
|
||||||
prisma.message.create({
|
if (toolMessages.length) {
|
||||||
|
await tx.message.createMany({
|
||||||
|
data: toolMessages.map((message) => ({
|
||||||
|
chatId: message.chatId,
|
||||||
|
role: message.role as any,
|
||||||
|
content: message.content,
|
||||||
|
name: message.name,
|
||||||
|
metadata: message.metadata as any,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.message.create({
|
||||||
data: {
|
data: {
|
||||||
chatId: call.chatId,
|
chatId: call.chatId,
|
||||||
role: "assistant" as any,
|
role: "assistant" as any,
|
||||||
content: outText,
|
content: outText,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
prisma.llmCall.update({
|
await tx.llmCall.update({
|
||||||
where: { id: call.id },
|
where: { id: call.id },
|
||||||
data: {
|
data: {
|
||||||
response: raw as any,
|
response: raw as any,
|
||||||
@@ -118,8 +131,8 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outputTokens: usage?.outputTokens,
|
outputTokens: usage?.outputTokens,
|
||||||
totalTokens: usage?.totalTokens,
|
totalTokens: usage?.totalTokens,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
]);
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: req.provider,
|
provider: req.provider,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import type OpenAI from "openai";
|
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
|
import { buildToolLogMessageData, runToolAwareOpenAIChatStream, type ToolExecutionEvent } from "./chat-tools.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
|
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
|
||||||
|
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||||
| { type: "delta"; text: string }
|
| { type: "delta"; text: string }
|
||||||
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
|
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
@@ -51,28 +52,39 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
let text = "";
|
let text = "";
|
||||||
let usage: StreamEvent extends any ? any : never;
|
let usage: StreamEvent extends any ? any : never;
|
||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai") {
|
if (req.provider === "openai" || req.provider === "xai") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
||||||
|
for await (const ev of runToolAwareOpenAIChatStream({
|
||||||
const stream = await client.chat.completions.create({
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages.map((m) => ({ role: m.role, content: m.content, name: m.name })) as any,
|
messages: req.messages,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_tokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
stream: true,
|
logContext: {
|
||||||
});
|
provider: req.provider,
|
||||||
|
model: req.model,
|
||||||
for await (const chunk of stream as any as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>) {
|
chatId,
|
||||||
const delta = chunk.choices?.[0]?.delta?.content ?? "";
|
},
|
||||||
if (delta) {
|
})) {
|
||||||
text += delta;
|
if (ev.type === "delta") {
|
||||||
yield { type: "delta", text: delta };
|
text += ev.text;
|
||||||
}
|
yield { type: "delta", text: ev.text };
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no guaranteed usage in stream mode across providers; leave empty for now
|
if (ev.type === "tool_call") {
|
||||||
|
toolMessages.push(buildToolLogMessageData(chatId, ev.event));
|
||||||
|
yield { type: "tool_call", event: ev.event };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = ev.result.raw;
|
||||||
|
usage = ev.result.usage;
|
||||||
|
text = ev.result.text;
|
||||||
|
}
|
||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
@@ -110,17 +122,29 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
}
|
}
|
||||||
// some streams end with message_stop
|
// some streams end with message_stop
|
||||||
}
|
}
|
||||||
|
raw = { streamed: true, provider: "anthropic" };
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`unknown provider: ${req.provider}`);
|
throw new Error(`unknown provider: ${req.provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction(async (tx) => {
|
||||||
prisma.message.create({
|
if (toolMessages.length) {
|
||||||
|
await tx.message.createMany({
|
||||||
|
data: toolMessages.map((message) => ({
|
||||||
|
chatId: message.chatId,
|
||||||
|
role: message.role as any,
|
||||||
|
content: message.content,
|
||||||
|
name: message.name,
|
||||||
|
metadata: message.metadata as any,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.message.create({
|
||||||
data: { chatId, role: "assistant" as any, content: text },
|
data: { chatId, role: "assistant" as any, content: text },
|
||||||
}),
|
});
|
||||||
prisma.llmCall.update({
|
await tx.llmCall.update({
|
||||||
where: { id: call.id },
|
where: { id: call.id },
|
||||||
data: {
|
data: {
|
||||||
response: raw as any,
|
response: raw as any,
|
||||||
@@ -129,8 +153,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
outputTokens: usage?.outputTokens,
|
outputTokens: usage?.outputTokens,
|
||||||
totalTokens: usage?.totalTokens,
|
totalTokens: usage?.totalTokens,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
]);
|
});
|
||||||
|
|
||||||
yield { type: "done", text, usage };
|
yield { type: "done", text, usage };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -16,10 +16,23 @@ type IncomingChatMessage = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
|
function sameMessage(
|
||||||
|
a: { role: string; content: string; name?: string | null },
|
||||||
|
b: { role: string; content: string; name?: string | null }
|
||||||
|
) {
|
||||||
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isToolCallLogMetadata(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return record.kind === "tool_call";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||||
|
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
@@ -27,13 +40,13 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
|||||||
const existing = await prisma.message.findMany({
|
const existing = await prisma.message.findMany({
|
||||||
where: { chatId },
|
where: { chatId },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { role: true, content: true, name: true },
|
select: { role: true, content: true, name: true, metadata: true },
|
||||||
});
|
});
|
||||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||||
|
|
||||||
let sharedPrefix = 0;
|
let sharedPrefix = 0;
|
||||||
const max = Math.min(existingNonAssistant.length, incoming.length);
|
const max = Math.min(existingNonAssistant.length, incoming.length);
|
||||||
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
|
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix], incoming[sharedPrefix])) {
|
||||||
sharedPrefix += 1;
|
sharedPrefix += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +761,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
for await (const ev of runMultiplexStream(body)) {
|
||||||
if (ev.type === "meta") send("meta", ev);
|
if (ev.type === "meta") send("meta", ev);
|
||||||
|
else if (ev.type === "tool_call") send("tool_call", ev.event);
|
||||||
else if (ev.type === "delta") send("delta", ev);
|
else if (ev.type === "delta") send("delta", ev);
|
||||||
else if (ev.type === "done") send("done", ev);
|
else if (ev.type === "done") send("done", ev);
|
||||||
else if (ev.type === "error") send("error", ev);
|
else if (ev.type === "error") send("error", ev);
|
||||||
|
|||||||
3
server/src/types/html-to-text.d.ts
vendored
Normal file
3
server/src/types/html-to-text.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module "html-to-text" {
|
||||||
|
export function convert(html: string, options?: unknown): string;
|
||||||
|
}
|
||||||
2
tui/.gitignore
vendored
Normal file
2
tui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
49
tui/README.md
Normal file
49
tui/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Sybil TUI
|
||||||
|
|
||||||
|
Terminal UI client for Sybil with a sidebar + workspace flow similar to the web app.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tui
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build/start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configuration is environment-only (no in-app settings).
|
||||||
|
|
||||||
|
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
|
||||||
|
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
|
||||||
|
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`)
|
||||||
|
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
|
||||||
|
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `SYBIL_API_BASE_URL` (fallback for API URL)
|
||||||
|
- `SYBIL_ADMIN_TOKEN` (fallback for token)
|
||||||
|
|
||||||
|
## Key Bindings
|
||||||
|
|
||||||
|
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
|
||||||
|
- `Esc` (in composer): exit input mode and focus sidebar
|
||||||
|
- `Up` / `Down` (in sidebar): move highlight
|
||||||
|
- `Page Up` / `Page Down` (in transcript): scroll the transcript by one page
|
||||||
|
- `Enter` in sidebar: load highlighted conversation/search
|
||||||
|
- `Enter` in composer: send message/search
|
||||||
|
- `n`: new chat draft
|
||||||
|
- `/`: new search draft
|
||||||
|
- `d`: delete selected chat/search
|
||||||
|
- `p`: cycle provider (chat mode)
|
||||||
|
- `m`: cycle model (chat mode)
|
||||||
|
- `r`: refresh collections + models
|
||||||
|
- `q` or `Ctrl+C`: quit
|
||||||
616
tui/package-lock.json
generated
Normal file
616
tui/package-lock.json
generated
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
{
|
||||||
|
"name": "sybil-tui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "sybil-tui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"blessed": "^0.1.81"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/blessed": "^0.1.25",
|
||||||
|
"@types/node": "^25.0.10",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/blessed": {
|
||||||
|
"version": "0.1.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz",
|
||||||
|
"integrity": "sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/blessed": {
|
||||||
|
"version": "0.1.81",
|
||||||
|
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
|
||||||
|
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"blessed": "bin/tput.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.3",
|
||||||
|
"@esbuild/android-arm": "0.27.3",
|
||||||
|
"@esbuild/android-arm64": "0.27.3",
|
||||||
|
"@esbuild/android-x64": "0.27.3",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.3",
|
||||||
|
"@esbuild/darwin-x64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.3",
|
||||||
|
"@esbuild/linux-arm": "0.27.3",
|
||||||
|
"@esbuild/linux-arm64": "0.27.3",
|
||||||
|
"@esbuild/linux-ia32": "0.27.3",
|
||||||
|
"@esbuild/linux-loong64": "0.27.3",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.3",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.3",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.3",
|
||||||
|
"@esbuild/linux-s390x": "0.27.3",
|
||||||
|
"@esbuild/linux-x64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.3",
|
||||||
|
"@esbuild/sunos-x64": "0.27.3",
|
||||||
|
"@esbuild/win32-arm64": "0.27.3",
|
||||||
|
"@esbuild/win32-ia32": "0.27.3",
|
||||||
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tui/package.json
Normal file
21
tui/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "sybil-tui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"blessed": "^0.1.81"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/blessed": "^0.1.25",
|
||||||
|
"@types/node": "^25.0.10",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
272
tui/src/api.ts
Normal file
272
tui/src/api.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type {
|
||||||
|
ChatDetail,
|
||||||
|
ChatSummary,
|
||||||
|
CompletionRequestMessage,
|
||||||
|
CompletionStreamHandlers,
|
||||||
|
ModelCatalogResponse,
|
||||||
|
Provider,
|
||||||
|
SearchDetail,
|
||||||
|
SearchRunRequest,
|
||||||
|
SearchStreamHandlers,
|
||||||
|
SearchSummary,
|
||||||
|
SessionStatus,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||||
|
body?: unknown;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SybilApiClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly token: string | null;
|
||||||
|
|
||||||
|
constructor(baseUrl: string, token: string | null) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifySession() {
|
||||||
|
return this.request<SessionStatus>("/v1/auth/session");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listModels() {
|
||||||
|
return this.request<ModelCatalogResponse>("/v1/models");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChats() {
|
||||||
|
const data = await this.request<{ chats: ChatSummary[] }>("/v1/chats");
|
||||||
|
return data.chats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChat(title?: string) {
|
||||||
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
|
method: "POST",
|
||||||
|
body: { title },
|
||||||
|
});
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChat(chatId: string) {
|
||||||
|
const data = await this.request<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||||
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChat(chatId: string) {
|
||||||
|
await this.request<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSearches() {
|
||||||
|
const data = await this.request<{ searches: SearchSummary[] }>("/v1/searches");
|
||||||
|
return data.searches;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSearch(body?: { title?: string; query?: string }) {
|
||||||
|
const data = await this.request<{ search: SearchSummary }>("/v1/searches", {
|
||||||
|
method: "POST",
|
||||||
|
body: body ?? {},
|
||||||
|
});
|
||||||
|
return data.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearch(searchId: string) {
|
||||||
|
const data = await this.request<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||||
|
return data.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearch(searchId: string) {
|
||||||
|
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCompletionStream(
|
||||||
|
body: {
|
||||||
|
chatId: string;
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
messages: CompletionRequestMessage[];
|
||||||
|
},
|
||||||
|
handlers: CompletionStreamHandlers,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
) {
|
||||||
|
await this.runSse(
|
||||||
|
"/v1/chat-completions/stream",
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
meta: handlers.onMeta,
|
||||||
|
tool_call: handlers.onToolCall,
|
||||||
|
delta: handlers.onDelta,
|
||||||
|
done: handlers.onDone,
|
||||||
|
error: handlers.onError,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSearchStream(
|
||||||
|
searchId: string,
|
||||||
|
body: SearchRunRequest,
|
||||||
|
handlers: SearchStreamHandlers,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
) {
|
||||||
|
await this.runSse(
|
||||||
|
`/v1/searches/${searchId}/run/stream`,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
search_results: handlers.onSearchResults,
|
||||||
|
search_error: handlers.onSearchError,
|
||||||
|
answer: handlers.onAnswer,
|
||||||
|
answer_error: handlers.onAnswerError,
|
||||||
|
done: handlers.onDone,
|
||||||
|
error: handlers.onError,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||||
|
const headers = new Headers(options?.headers ?? {});
|
||||||
|
const hasBody = options?.body !== undefined;
|
||||||
|
if (hasBody && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (this.token) {
|
||||||
|
headers.set("Authorization", `Bearer ${this.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: options?.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
if (hasBody) {
|
||||||
|
init.body = JSON.stringify(options?.body);
|
||||||
|
}
|
||||||
|
if (options?.signal) {
|
||||||
|
init.signal = options.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await this.readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSse(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
handlers: Record<string, ((payload: any) => void) | undefined>,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
if (this.token) {
|
||||||
|
headers.set("Authorization", `Bearer ${this.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
if (options?.signal) {
|
||||||
|
init.signal = options.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await this.readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No response stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let eventName = "message";
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
|
||||||
|
const flushEvent = () => {
|
||||||
|
if (!dataLines.length) {
|
||||||
|
eventName = "message";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataText = dataLines.join("\n");
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(dataText);
|
||||||
|
} catch {
|
||||||
|
payload = { message: dataText };
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers[eventName]?.(payload);
|
||||||
|
dataLines = [];
|
||||||
|
eventName = "message";
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newlineIndex = buffer.indexOf("\n");
|
||||||
|
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const rawLine = buffer.slice(0, newlineIndex);
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
flushEvent();
|
||||||
|
} else if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
|
||||||
|
newlineIndex = buffer.indexOf("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.length) {
|
||||||
|
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readErrorMessage(response: Response) {
|
||||||
|
const fallback = `${response.status} ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { message?: string };
|
||||||
|
if (typeof body.message === "string" && body.message.trim()) {
|
||||||
|
return body.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tui/src/config.ts
Normal file
48
tui/src/config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("SYBIL_TUI_API_BASE_URL cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid SYBIL_TUI_API_BASE_URL: ${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
parsed.pathname = normalizedPath || "/";
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProvider(value: string | undefined): Provider {
|
||||||
|
const trimmed = value?.trim().toLowerCase();
|
||||||
|
if (!trimmed) return "openai";
|
||||||
|
if (PROVIDERS.includes(trimmed as Provider)) return trimmed as Provider;
|
||||||
|
throw new Error(`Invalid SYBIL_TUI_DEFAULT_PROVIDER: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined, fallback: number) {
|
||||||
|
if (!value?.trim()) return fallback;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`Invalid positive integer value: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseUrlValue =
|
||||||
|
process.env.SYBIL_TUI_API_BASE_URL?.trim() || process.env.SYBIL_API_BASE_URL?.trim() || "http://127.0.0.1:8787";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
apiBaseUrl: normalizeBaseUrl(apiBaseUrlValue),
|
||||||
|
adminToken: process.env.SYBIL_TUI_ADMIN_TOKEN?.trim() || process.env.SYBIL_ADMIN_TOKEN?.trim() || null,
|
||||||
|
defaultProvider: parseProvider(process.env.SYBIL_TUI_DEFAULT_PROVIDER),
|
||||||
|
defaultModel: process.env.SYBIL_TUI_DEFAULT_MODEL?.trim() || null,
|
||||||
|
searchNumResults: parsePositiveInt(process.env.SYBIL_TUI_SEARCH_NUM_RESULTS, 10),
|
||||||
|
};
|
||||||
1450
tui/src/index.ts
Normal file
1450
tui/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
140
tui/src/types.ts
Normal file
140
tui/src/types.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export type Provider = "openai" | "anthropic" | "xai";
|
||||||
|
|
||||||
|
export type ProviderModelInfo = {
|
||||||
|
models: string[];
|
||||||
|
loadedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelCatalogResponse = {
|
||||||
|
providers: Record<Provider, ProviderModelInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
initiatedProvider: Provider | null;
|
||||||
|
initiatedModel: string | null;
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
query: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
|
content: string;
|
||||||
|
name: string | null;
|
||||||
|
metadata: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallEvent = {
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
summary: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
resultPreview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
initiatedProvider: Provider | null;
|
||||||
|
initiatedModel: string | null;
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
|
messages: Message[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchResultItem = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
rank: number;
|
||||||
|
title: string | null;
|
||||||
|
url: string;
|
||||||
|
publishedDate: string | null;
|
||||||
|
author: string | null;
|
||||||
|
text: string | null;
|
||||||
|
highlights: string[] | null;
|
||||||
|
highlightScores: number[] | null;
|
||||||
|
score: number | null;
|
||||||
|
favicon: string | null;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
query: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
requestId: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
answerText: string | null;
|
||||||
|
answerRequestId: string | null;
|
||||||
|
answerCitations: Array<{
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
title?: string | null;
|
||||||
|
publishedDate?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
}> | null;
|
||||||
|
answerError: string | null;
|
||||||
|
results: SearchResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchRunRequest = {
|
||||||
|
query?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: "auto" | "fast" | "deep" | "instant";
|
||||||
|
numResults?: number;
|
||||||
|
includeDomains?: string[];
|
||||||
|
excludeDomains?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompletionRequestMessage = {
|
||||||
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
|
content: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompletionStreamHandlers = {
|
||||||
|
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
|
||||||
|
onToolCall?: (payload: ToolCallEvent) => void;
|
||||||
|
onDelta?: (payload: { text: string }) => void;
|
||||||
|
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||||
|
onError?: (payload: { message: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchStreamHandlers = {
|
||||||
|
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
|
||||||
|
onSearchError?: (payload: { error: string }) => void;
|
||||||
|
onAnswer?: (payload: { answerText: string | null; answerRequestId: string | null; answerCitations: SearchDetail["answerCitations"] }) => void;
|
||||||
|
onAnswerError?: (payload: { error: string }) => void;
|
||||||
|
onDone?: (payload: { search: SearchDetail }) => void;
|
||||||
|
onError?: (payload: { message: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionStatus = {
|
||||||
|
authenticated: true;
|
||||||
|
mode: "open" | "token";
|
||||||
|
};
|
||||||
16
tui/tsconfig.json
Normal file
16
tui/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
344
web/src/App.tsx
344
web/src/App.tsx
@@ -27,6 +27,7 @@ import {
|
|||||||
type Message,
|
type Message,
|
||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
type SearchSummary,
|
type SearchSummary,
|
||||||
|
type ToolCallEvent,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -118,9 +119,8 @@ function loadStoredModelPreferences() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
function pickProviderModel(options: string[], preferred: string | null) {
|
||||||
if (fallback && options.includes(fallback)) return fallback;
|
if (preferred?.trim()) return preferred.trim();
|
||||||
if (preferred && options.includes(preferred)) return preferred;
|
|
||||||
return options[0] ?? "";
|
return options[0] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +139,54 @@ function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "las
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolLogMetadata = {
|
||||||
|
kind: "tool_call";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
status?: "completed" | "failed";
|
||||||
|
summary?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: string | null;
|
||||||
|
resultPreview?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.kind !== "tool_call") return null;
|
||||||
|
return record as ToolLogMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolCallLogMessage(message: Message) {
|
||||||
|
return asToolLogMetadata(message.metadata) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||||
|
return {
|
||||||
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
|
createdAt: event.completedAt ?? new Date().toISOString(),
|
||||||
|
role: "tool",
|
||||||
|
content: event.summary,
|
||||||
|
name: event.name,
|
||||||
|
metadata: {
|
||||||
|
kind: "tool_call",
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName: event.name,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type ModelComboboxProps = {
|
type ModelComboboxProps = {
|
||||||
options: string[];
|
options: string[];
|
||||||
value: string;
|
value: string;
|
||||||
@@ -148,34 +196,46 @@ type ModelComboboxProps = {
|
|||||||
|
|
||||||
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const normalizedDraftValue = draftValue.trim();
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = normalizedDraftValue.toLowerCase();
|
||||||
if (!needle) return options;
|
if (!needle) return options;
|
||||||
return options.filter((option) => option.toLowerCase().includes(needle));
|
return options.filter((option) => option.toLowerCase().includes(needle));
|
||||||
}, [options, query]);
|
}, [normalizedDraftValue, options]);
|
||||||
|
const hasExactOption = options.includes(normalizedDraftValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
setDraftValue(value);
|
||||||
|
}, [open, value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const commitDraftValue = () => {
|
||||||
|
onChange(normalizedDraftValue);
|
||||||
|
setDraftValue(normalizedDraftValue);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (rootRef.current?.contains(event.target as Node)) return;
|
if (rootRef.current?.contains(event.target as Node)) return;
|
||||||
setOpen(false);
|
commitDraftValue();
|
||||||
setQuery("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery("");
|
setDraftValue(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointerdown", handlePointerDown);
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
@@ -184,51 +244,82 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
|||||||
window.removeEventListener("pointerdown", handlePointerDown);
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [commitDraftValue, open, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={rootRef}>
|
<div className="relative" ref={rootRef}>
|
||||||
<button
|
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||||
type="button"
|
|
||||||
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (disabled) return;
|
|
||||||
setOpen((current) => !current);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">{value || "Select model"}</span>
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={draftValue}
|
||||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
onFocus={() => {
|
||||||
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
|
if (disabled) return;
|
||||||
placeholder="Filter models"
|
setDraftValue(value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onInput={(event) => {
|
||||||
|
setDraftValue(event.currentTarget.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
commitDraftValue();
|
||||||
|
}}
|
||||||
|
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Select or type model"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-2 shrink-0 text-muted-foreground disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (open) {
|
||||||
|
commitDraftValue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftValue(value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Toggle model options"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-full rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45">
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{normalizedDraftValue && !hasExactOption ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||||
|
onClick={commitDraftValue}
|
||||||
|
>
|
||||||
|
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="truncate">Use "{normalizedDraftValue}"</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{filteredOptions.length ? (
|
{filteredOptions.length ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(option);
|
onChange(option);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery("");
|
setDraftValue(option);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
|
||||||
<span className="truncate">{option}</span>
|
<span className="truncate">{option}</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : !normalizedDraftValue ? (
|
||||||
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
|
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -287,6 +378,32 @@ function formatDate(value: string) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSidebarSectionLabel(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
const startOfItemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
const dayDelta = Math.floor((startOfToday - startOfItemDay) / dayMs);
|
||||||
|
|
||||||
|
if (dayDelta <= 0) return "TODAY";
|
||||||
|
if (dayDelta < 7) return "LAST 7 DAYS";
|
||||||
|
return "EARLIER";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebarSections(items: SidebarItem[]) {
|
||||||
|
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||||
|
const label = getSidebarSectionLabel(item.updatedAt);
|
||||||
|
const section = sections.find((candidate) => candidate.label === label);
|
||||||
|
if (section) {
|
||||||
|
section.items.push(item);
|
||||||
|
} else {
|
||||||
|
sections.push({ label, items: [item] });
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
authTokenInput,
|
authTokenInput,
|
||||||
@@ -331,6 +448,7 @@ export default function App() {
|
|||||||
const wasSendingRef = useRef(false);
|
const wasSendingRef = useRef(false);
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
|
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||||
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
||||||
const hasSyncedSelectionHistoryRef = useRef(false);
|
const hasSyncedSelectionHistoryRef = useRef(false);
|
||||||
|
|
||||||
@@ -351,6 +469,17 @@ export default function App() {
|
|||||||
}, [composer]);
|
}, [composer]);
|
||||||
|
|
||||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||||
|
const filteredSidebarItems = useMemo(() => {
|
||||||
|
const query = sidebarQuery.trim().toLowerCase();
|
||||||
|
if (!query) return sidebarItems;
|
||||||
|
return sidebarItems.filter((item) => {
|
||||||
|
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
||||||
|
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
}, [sidebarItems, sidebarQuery]);
|
||||||
|
const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]);
|
||||||
|
|
||||||
const resetWorkspaceState = () => {
|
const resetWorkspaceState = () => {
|
||||||
setChats([]);
|
setChats([]);
|
||||||
@@ -496,10 +625,11 @@ export default function App() {
|
|||||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (model.trim()) return;
|
||||||
setModel((current) => {
|
setModel((current) => {
|
||||||
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current);
|
return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]);
|
||||||
});
|
});
|
||||||
}, [provider, providerModelOptions, providerModelPreferences]);
|
}, [model, provider, providerModelOptions, providerModelPreferences]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -707,6 +837,7 @@ export default function App() {
|
|||||||
role: "user",
|
role: "user",
|
||||||
content,
|
content,
|
||||||
name: null,
|
name: null,
|
||||||
|
metadata: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const optimisticAssistantMessage: Message = {
|
const optimisticAssistantMessage: Message = {
|
||||||
@@ -715,6 +846,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
|
metadata: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setPendingChatState({
|
setPendingChatState({
|
||||||
@@ -758,7 +890,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestMessages: CompletionRequestMessage[] = [
|
const requestMessages: CompletionRequestMessage[] = [
|
||||||
...baseChat.messages.map((message) => ({
|
...baseChat.messages
|
||||||
|
.filter((message) => !isToolCallLogMessage(message))
|
||||||
|
.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
...(message.name ? { name: message.name } : {}),
|
...(message.name ? { name: message.name } : {}),
|
||||||
@@ -813,6 +947,35 @@ export default function App() {
|
|||||||
if (payload.chatId !== chatId) return;
|
if (payload.chatId !== chatId) return;
|
||||||
setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current));
|
setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current));
|
||||||
},
|
},
|
||||||
|
onToolCall: (payload) => {
|
||||||
|
setPendingChatState((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
if (
|
||||||
|
current.messages.some(
|
||||||
|
(message) =>
|
||||||
|
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolMessage = buildOptimisticToolMessage(payload);
|
||||||
|
const assistantIndex = current.messages.findIndex(
|
||||||
|
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||||
|
);
|
||||||
|
if (assistantIndex < 0) {
|
||||||
|
return { ...current, messages: current.messages.concat(toolMessage) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
messages: [
|
||||||
|
...current.messages.slice(0, assistantIndex),
|
||||||
|
toolMessage,
|
||||||
|
...current.messages.slice(assistantIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
setPendingChatState((current) => {
|
setPendingChatState((current) => {
|
||||||
@@ -1046,12 +1209,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="app-grid-surface h-full p-0 md:p-2">
|
||||||
<div className="flex h-full w-full overflow-hidden bg-background">
|
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||||
{isMobileSidebarOpen ? (
|
{isMobileSidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="fixed inset-0 z-30 bg-black/45 md:hidden"
|
className="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm md:hidden"
|
||||||
onClick={() => setIsMobileSidebarOpen(false)}
|
onClick={() => setIsMobileSidebarOpen(false)}
|
||||||
aria-label="Close sidebar"
|
aria-label="Close sidebar"
|
||||||
/>
|
/>
|
||||||
@@ -1059,22 +1222,41 @@ export default function App() {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
"glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
|
||||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2 p-3">
|
<div className="px-4 pb-4 pt-5">
|
||||||
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
|
SYBIL
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 px-3 pb-3">
|
||||||
|
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New chat
|
New chat
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
New search
|
New search
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||||
|
<input
|
||||||
|
value={sidebarQuery}
|
||||||
|
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
||||||
|
placeholder="Search chats"
|
||||||
|
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<Separator className="bg-violet-300/10" />
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||||
@@ -1082,7 +1264,13 @@ export default function App() {
|
|||||||
Start a chat or run your first search.
|
Start a chat or run your first search.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{sidebarItems.map((item) => {
|
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
|
||||||
|
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
|
||||||
|
) : null}
|
||||||
|
{sidebarSections.map((section) => (
|
||||||
|
<div key={section.label} className="mb-4">
|
||||||
|
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
||||||
|
{section.items.map((item) => {
|
||||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||||
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
||||||
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
||||||
@@ -1091,8 +1279,10 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
key={`${item.kind}-${item.id}`}
|
key={`${item.kind}-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
"mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
|
||||||
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
|
active
|
||||||
|
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
|
||||||
|
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1104,23 +1294,30 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||||
|
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
</span>
|
||||||
|
<p className="truncate text-sm font-semibold">{item.title}</p>
|
||||||
|
<p className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs">
|
|
||||||
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
|
|
||||||
{initiatedLabel ? (
|
{initiatedLabel ? (
|
||||||
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
|
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<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 px-4 py-3">
|
<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">
|
<div className="flex items-start gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1134,24 +1331,20 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
|
||||||
{isSearchMode ? " • Exa Search" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
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}
|
value={provider}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
setProvider(nextProvider);
|
setProvider(nextProvider);
|
||||||
const options = getModelOptions(modelCatalog, nextProvider);
|
const options = getModelOptions(modelCatalog, nextProvider);
|
||||||
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
|
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||||
}}
|
}}
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
>
|
>
|
||||||
@@ -1162,18 +1355,19 @@ export default function App() {
|
|||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
options={providerModelOptions}
|
options={providerModelOptions}
|
||||||
value={model}
|
value={model}
|
||||||
disabled={isSending || providerModelOptions.length === 0}
|
disabled={isSending}
|
||||||
onChange={(nextModel) => {
|
onChange={(nextModel) => {
|
||||||
setModel(nextModel);
|
const normalizedModel = nextModel.trim();
|
||||||
|
setModel(normalizedModel);
|
||||||
setProviderModelPreferences((current) => ({
|
setProviderModelPreferences((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[provider]: nextModel,
|
[provider]: normalizedModel || null,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
|
<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" />
|
<Globe2 className="mr-2 h-4 w-4" />
|
||||||
Search mode
|
Search mode
|
||||||
</div>
|
</div>
|
||||||
@@ -1183,7 +1377,7 @@ export default function App() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={transcriptContainerRef}
|
ref={transcriptContainerRef}
|
||||||
className={cn("flex-1 overflow-y-auto px-3 pt-6 md:px-10", isSearchMode ? "pb-6" : "pb-28 md:pb-40")}
|
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44"
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
const container = transcriptContainerRef.current;
|
const container = transcriptContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1199,8 +1393,8 @@ export default function App() {
|
|||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t p-3 md:p-4">
|
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
|
||||||
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
|
<div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="composer-input"
|
id="composer-input"
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -1217,13 +1411,13 @@ export default function App() {
|
|||||||
void handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
|
||||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0"
|
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
||||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
<Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1234,13 +1428,13 @@ export default function App() {
|
|||||||
{contextMenu ? (
|
{contextMenu ? (
|
||||||
<div
|
<div
|
||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
|
className="fixed z-50 min-w-40 rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45"
|
||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||||
onClick={() => void handleDeleteFromContextMenu()}
|
onClick={() => void handleDeleteFromContextMenu()}
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,14 +12,20 @@ type Props = {
|
|||||||
|
|
||||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#45215f_0%,#2a183d_45%,#191227_100%)] p-4">
|
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl border bg-[hsl(276_32%_14%)] p-6 shadow-xl shadow-violet-950/45">
|
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
|
SYBIL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-5 flex items-start gap-3">
|
<div className="mb-5 flex items-start gap-3">
|
||||||
<div className="rounded-lg bg-violet-200 p-2 text-violet-900">
|
<div className="rounded-lg border border-cyan-300/25 bg-cyan-400/12 p-2 text-cyan-200">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">Sign in to Sybil</h1>
|
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +44,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
|||||||
value={authTokenInput}
|
value={authTokenInput}
|
||||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||||
disabled={isSigningIn}
|
disabled={isSigningIn}
|
||||||
|
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
||||||
/>
|
/>
|
||||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
||||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Message } from "@/lib/api";
|
import type { Message } from "@/lib/api";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
|
import { Globe2, Link2, Wrench } from "lucide-preact";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -8,14 +9,63 @@ type Props = {
|
|||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToolLogMetadata = {
|
||||||
|
kind: "tool_call";
|
||||||
|
toolName?: string;
|
||||||
|
status?: "completed" | "failed";
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.kind !== "tool_call") return null;
|
||||||
|
return record as ToolLogMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
||||||
|
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
||||||
|
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
||||||
|
return `Ran tool '${toolName}'.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolIconName(toolName: string | null | undefined) {
|
||||||
|
const lowered = toolName?.toLowerCase() ?? "";
|
||||||
|
if (lowered.includes("search")) return "search";
|
||||||
|
if (lowered.includes("url") || lowered.includes("fetch") || lowered.includes("http")) return "fetch";
|
||||||
|
return "generic";
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
|
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";
|
||||||
|
return (
|
||||||
|
<div key={message.id} className="flex justify-start">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||||
|
isFailed
|
||||||
|
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
|
||||||
|
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||||
|
<span>{getToolSummary(message, toolLogMetadata)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +73,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[85%]",
|
"max-w-[85%]",
|
||||||
isUser ? "rounded-2xl bg-violet-900/80 px-4 py-3 text-sm leading-6 text-fuchsia-50" : "text-base leading-7 text-fuchsia-100"
|
isUser
|
||||||
|
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||||
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPendingAssistant ? (
|
{isPendingAssistant ? (
|
||||||
@@ -35,7 +87,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
markdown={message.content}
|
markdown={message.content}
|
||||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-fuchsia-100")}
|
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +96,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
})}
|
})}
|
||||||
{isSending && !hasPendingAssistant ? (
|
{isSending && !hasPendingAssistant ? (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="max-w-[85%] text-base leading-7 text-fuchsia-100">
|
<div className="max-w-[85%] text-base leading-7 text-violet-50">
|
||||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function SearchResultsPanel({
|
|||||||
{search?.query ? (
|
{search?.query ? (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="text-sm text-muted-foreground">Results for</p>
|
<p className="text-sm text-muted-foreground">Results for</p>
|
||||||
<h2 className="mt-1 break-words text-xl font-semibold">{search.query}</h2>
|
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
||||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||||
@@ -148,8 +148,8 @@ export function SearchResultsPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
||||||
<section className="mb-6 rounded-xl border border-violet-400/35 bg-[hsl(276_31%_15%)] p-4">
|
<section className="mb-6 rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(240_46%_8%_/_0.94),hsl(260_40%_12%_/_0.88))] p-4 shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-violet-300/90">Answer</p>
|
<p className="text-xs font-semibold uppercase text-violet-300/90">Answer</p>
|
||||||
{(isAnswerLoading || hasAnswerText) ? (
|
{(isAnswerLoading || hasAnswerText) ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -172,7 +172,7 @@ export function SearchResultsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
|
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(276_31%_15%)] to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(252_42%_10%)] to-transparent" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-5">
|
<div className="mt-2 h-5">
|
||||||
@@ -199,7 +199,7 @@ export function SearchResultsPanel({
|
|||||||
href={citation.href}
|
href={citation.href}
|
||||||
target={openLinksInNewTab ? "_blank" : undefined}
|
target={openLinksInNewTab ? "_blank" : undefined}
|
||||||
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
||||||
className="max-w-full truncate rounded-md border border-violet-400/40 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
|
className="max-w-full truncate rounded-md border border-violet-300/28 bg-violet-300/8 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
|
||||||
>
|
>
|
||||||
<span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span>
|
<span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span>
|
||||||
{citation.label}
|
{citation.label}
|
||||||
@@ -225,23 +225,23 @@ export function SearchResultsPanel({
|
|||||||
<article
|
<article
|
||||||
key={result.id}
|
key={result.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border border-border bg-[hsl(276_30%_13%)] px-4 py-4 shadow-sm transition-colors",
|
"rounded-lg border border-violet-300/16 bg-[linear-gradient(135deg,hsl(238_44%_7%_/_0.92),hsl(250_34%_10%_/_0.86))] px-4 py-4 shadow-sm transition-colors",
|
||||||
index === activeResultIndex && "border-violet-300 ring-1 ring-violet-300/80"
|
index === activeResultIndex && "border-violet-300/55 ring-1 ring-violet-300/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="truncate text-xs text-violet-300/85">{formatHost(result.url)}</p>
|
<p className="truncate text-xs text-cyan-200/85">{formatHost(result.url)}</p>
|
||||||
<a
|
<a
|
||||||
href={result.url}
|
href={result.url}
|
||||||
target={openLinksInNewTab ? "_blank" : undefined}
|
target={openLinksInNewTab ? "_blank" : undefined}
|
||||||
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
||||||
className="mt-1 block break-words text-lg font-medium text-violet-300 hover:underline"
|
className="mt-1 block break-words text-lg font-medium text-violet-200 hover:underline"
|
||||||
>
|
>
|
||||||
{result.title || result.url}
|
{result.title || result.url}
|
||||||
</a>
|
</a>
|
||||||
{(result.publishedDate || result.author) && (
|
{(result.publishedDate || result.author) && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
|
||||||
)}
|
)}
|
||||||
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/90">{result.url}</p> : null}
|
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/82">{result.url}</p> : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import type { JSX } from "preact";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
default:
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border border-violet-300/35 bg-[linear-gradient(135deg,hsl(252_92%_64%_/_0.95),hsl(274_84%_35%_/_0.95))] text-primary-foreground shadow-sm hover:border-violet-200/55 hover:brightness-110",
|
||||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
secondary:
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
"border border-violet-300/18 bg-secondary/78 text-secondary-foreground shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] hover:border-violet-300/32 hover:bg-secondary",
|
||||||
|
outline: "border border-input bg-background/76 hover:border-violet-300/45 hover:bg-accent/65 hover:text-accent-foreground",
|
||||||
|
ghost: "text-muted-foreground hover:bg-accent/65 hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function Input({ className, ...props }: JSX.InputHTMLAttributes<HTMLInput
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"flex h-9 w-full rounded-md border border-input bg-background/78 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function Textarea({ className, ...props }: JSX.TextareaHTMLAttributes<HTM
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background/78 px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Orbitron:wght@700;800;900&display=swap");
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 282 33% 8%;
|
color-scheme: dark;
|
||||||
--foreground: 300 35% 95%;
|
--background: 235 45% 4%;
|
||||||
--muted: 287 24% 16%;
|
--foreground: 258 36% 96%;
|
||||||
--muted-foreground: 297 16% 72%;
|
--muted: 246 30% 13%;
|
||||||
--border: 287 24% 24%;
|
--muted-foreground: 247 18% 68%;
|
||||||
--input: 287 24% 24%;
|
--border: 251 35% 20%;
|
||||||
--ring: 264 76% 70%;
|
--input: 252 42% 24%;
|
||||||
--primary: 266 72% 67%;
|
--ring: 258 92% 70%;
|
||||||
--primary-foreground: 296 45% 12%;
|
--primary: 264 93% 66%;
|
||||||
--secondary: 277 24% 19%;
|
--primary-foreground: 265 55% 98%;
|
||||||
--secondary-foreground: 302 42% 94%;
|
--secondary: 244 32% 14%;
|
||||||
--accent: 279 24% 22%;
|
--secondary-foreground: 254 38% 94%;
|
||||||
--accent-foreground: 305 45% 94%;
|
--accent: 188 86% 50%;
|
||||||
--radius: 0.65rem;
|
--accent-foreground: 230 45% 8%;
|
||||||
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
scrollbar-color: hsl(263 92% 68% / 0.42) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -31,8 +36,47 @@ body,
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground antialiased;
|
@apply bg-background text-foreground antialiased;
|
||||||
background-image: radial-gradient(circle at top, hsl(274 42% 18%) 0%, hsl(271 34% 12%) 42%, hsl(266 32% 7%) 100%);
|
background-color: hsl(var(--background));
|
||||||
font-family: "Soehne", "Avenir Next", "Segoe UI", sans-serif;
|
background-image:
|
||||||
|
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||||
|
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||||
|
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: hsl(264 92% 68% / 0.45);
|
||||||
|
color: hsl(258 36% 98%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sybil-wordmark {
|
||||||
|
font-family: "Orbitron", "Inter", sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid-surface {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(hsl(244 48% 20% / 0.18) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, hsl(244 48% 20% / 0.14) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||||
|
hsl(236 48% 6%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 hsl(252 90% 86% / 0.08),
|
||||||
|
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content {
|
.md-content {
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ export type Message = {
|
|||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
metadata: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallEvent = {
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
summary: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
resultPreview?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
@@ -113,6 +127,7 @@ type CompletionResponse = {
|
|||||||
|
|
||||||
type CompletionStreamHandlers = {
|
type CompletionStreamHandlers = {
|
||||||
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
|
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
|
||||||
|
onToolCall?: (payload: ToolCallEvent) => void;
|
||||||
onDelta?: (payload: { text: string }) => void;
|
onDelta?: (payload: { text: string }) => void;
|
||||||
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
@@ -415,6 +430,7 @@ export async function runCompletionStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
|||||||
Reference in New Issue
Block a user