Compare commits
5 Commits
adb9e15b6c
...
codex/chat
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9336acf9 | |||
| fa429dcbb3 | |||
| 05989e9fae | |||
| 70b831557f | |||
| cb368a4005 |
3
Tiltfile
3
Tiltfile
@@ -1,5 +1,8 @@
|
|||||||
update_settings(max_parallel_updates=4)
|
update_settings(max_parallel_updates=4)
|
||||||
|
|
||||||
|
load('ext://dotenv', 'dotenv')
|
||||||
|
dotenv() # defaults to .env in the current directory
|
||||||
|
|
||||||
local_resource(
|
local_resource(
|
||||||
"server",
|
"server",
|
||||||
cmd="npm ci --no-audit --no-fund",
|
cmd="npm ci --no-audit --no-fund",
|
||||||
|
|||||||
@@ -135,6 +135,16 @@ Behavior notes:
|
|||||||
### `GET /v1/searches/:searchId`
|
### `GET /v1/searches/:searchId`
|
||||||
- Response: `{ "search": SearchDetail }`
|
- Response: `{ "search": SearchDetail }`
|
||||||
|
|
||||||
|
### `POST /v1/searches/:searchId/chat`
|
||||||
|
- Body: `{ "title"?: string }`
|
||||||
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
- Not found: `404 { "message": "search not found" }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
|
||||||
|
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
|
||||||
|
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.
|
||||||
|
|
||||||
### `POST /v1/searches/:searchId/run`
|
### `POST /v1/searches/:searchId/run`
|
||||||
- Body:
|
- Body:
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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.
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ actor SybilAPIClient {
|
|||||||
return response.search
|
return response.search
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/searches/\(searchID)/chat",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(SearchChatCreateBody(title: title)),
|
||||||
|
responseType: ChatCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
func deleteSearch(searchID: String) async throws {
|
func deleteSearch(searchID: String) async throws {
|
||||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -552,3 +562,7 @@ private struct SearchCreateBody: Encodable {
|
|||||||
var title: String?
|
var title: String?
|
||||||
var query: String?
|
var query: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SearchChatCreateBody: Encodable {
|
||||||
|
var title: String?
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -86,11 +86,16 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(alignment: .top) {
|
||||||
|
if isUser {
|
||||||
|
Spacer(minLength: 44)
|
||||||
|
}
|
||||||
|
|
||||||
if let toolCallMetadata {
|
if let toolCallMetadata {
|
||||||
ToolCallActivityChip(
|
ToolCallActivityChip(
|
||||||
metadata: toolCallMetadata,
|
metadata: toolCallMetadata,
|
||||||
fallbackContent: message.content
|
fallbackContent: message.content,
|
||||||
|
createdAt: message.createdAt
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -98,9 +103,9 @@ private struct MessageBubble: View {
|
|||||||
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)
|
||||||
@@ -108,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)
|
||||||
@@ -132,24 +135,50 @@ 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 {
|
private struct ToolCallActivityChip: View {
|
||||||
var metadata: ToolCallMetadata
|
var metadata: ToolCallMetadata
|
||||||
var fallbackContent: String
|
var fallbackContent: String
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
private var summary: String {
|
private var summary: String {
|
||||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||||
return text
|
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 {
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return fallbackContent
|
return fallbackContent
|
||||||
}
|
}
|
||||||
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
|
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 {
|
private var iconName: String {
|
||||||
let name = (metadata.toolName ?? "").lowercased()
|
let name = (metadata.toolName ?? "").lowercased()
|
||||||
if name.contains("search") {
|
if name.contains("search") {
|
||||||
@@ -165,26 +194,60 @@ private struct ToolCallActivityChip: View {
|
|||||||
(metadata.status ?? "").lowercased() == "failed"
|
(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 {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(alignment: .top, spacing: 11) {
|
||||||
Image(systemName: iconName)
|
ZStack {
|
||||||
.font(.system(size: 12, weight: .semibold))
|
RoundedRectangle(cornerRadius: 9)
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary)
|
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||||
Text(summary)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9))
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12))
|
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 9)
|
||||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1)
|
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
Image(systemName: iconName)
|
||||||
.frame(maxWidth: 460, alignment: .leading)
|
.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))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -58,7 +58,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
toolCallId: object["toolCallId"]?.stringValue,
|
toolCallId: object["toolCallId"]?.stringValue,
|
||||||
toolName: object["toolName"]?.stringValue ?? name,
|
toolName: object["toolName"]?.stringValue ?? name,
|
||||||
status: object["status"]?.stringValue,
|
status: object["status"]?.stringValue,
|
||||||
summary: object["summary"]?.stringValue
|
summary: object["summary"]?.stringValue,
|
||||||
|
durationMs: object["durationMs"]?.numberValue.map(Int.init),
|
||||||
|
error: object["error"]?.stringValue,
|
||||||
|
resultPreview: object["resultPreview"]?.stringValue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
|
|||||||
public var toolName: String?
|
public var toolName: String?
|
||||||
public var status: String?
|
public var status: String?
|
||||||
public var summary: String?
|
public var summary: String?
|
||||||
|
public var durationMs: Int?
|
||||||
|
public var error: String?
|
||||||
|
public var resultPreview: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum JSONValue: Codable, Hashable, Sendable {
|
public enum JSONValue: Codable, Hashable, Sendable {
|
||||||
@@ -136,6 +142,13 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var numberValue: Double? {
|
||||||
|
if case let .number(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
public var objectValue: [String: JSONValue]? {
|
public var objectValue: [String: JSONValue]? {
|
||||||
if case let .object(value) = self {
|
if case let .object(value) = self {
|
||||||
return value
|
return value
|
||||||
@@ -412,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 {
|
||||||
@@ -421,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)
|
||||||
|
|||||||
@@ -5,24 +5,61 @@ struct SybilSearchResultsView: View {
|
|||||||
var search: SearchDetail?
|
var search: SearchDetail?
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
|
var isStartingChat: Bool = false
|
||||||
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if let query = search?.query, !query.isEmpty {
|
if let query = search?.query, !query.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let onStartChat {
|
||||||
|
Button {
|
||||||
|
onStartChat()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isStartingChat {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.text)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text(isStartingChat ? "Starting chat..." : "Chat with results")
|
||||||
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(SybilTheme.primary.opacity(0.14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canStartChat)
|
||||||
|
.opacity(canStartChat ? 1 : 0.55)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
||||||
@@ -36,14 +73,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 +92,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,13 +113,20 @@ struct SybilSearchResultsView: View {
|
|||||||
return "\(count) result\(count == 1 ? "" : "s")"
|
return "\(count) result\(count == 1 ? "" : "s")"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canStartChat: Bool {
|
||||||
|
guard let search, !isLoading, !isRunning, !isStartingChat else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return search.answerText?.isEmpty == false || !search.results.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
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 +134,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 +157,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 +168,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 +184,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 +213,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 +266,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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isSending = false
|
var isSending = false
|
||||||
|
var isCreatingSearchChat = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
@@ -111,11 +112,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 {
|
||||||
@@ -198,20 +203,20 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayedMessages: [Message] {
|
var displayedMessages: [Message] {
|
||||||
let canonical = selectedChat?.messages ?? []
|
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||||
guard let pending = pendingChatState else {
|
guard let pending = pendingChatState else {
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
if let pendingID = pending.chatID {
|
if let pendingID = pending.chatID {
|
||||||
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
|
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
|
||||||
return pending.messages
|
return displayableMessages(pending.messages)
|
||||||
}
|
}
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return pending.messages
|
return displayableMessages(pending.messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
return canonical
|
return canonical
|
||||||
@@ -222,7 +227,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 +341,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
|
||||||
@@ -460,6 +474,36 @@ final class SybilViewModel {
|
|||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startChatFromSelectedSearch() async {
|
||||||
|
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingSearchChat = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let client = try client()
|
||||||
|
let chat = try await client.createChatFromSearch(searchID: search.id)
|
||||||
|
draftKind = nil
|
||||||
|
pendingChatState = nil
|
||||||
|
composer = ""
|
||||||
|
|
||||||
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
|
chats.insert(chat, at: 0)
|
||||||
|
|
||||||
|
selectedItem = .chat(chat.id)
|
||||||
|
selectedSearch = nil
|
||||||
|
|
||||||
|
await refreshCollections(preferredSelection: .chat(chat.id))
|
||||||
|
} catch {
|
||||||
|
errorMessage = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingSearchChat = false
|
||||||
|
}
|
||||||
|
|
||||||
private func loadInitialData(using client: SybilAPIClient) async {
|
private func loadInitialData(using client: SybilAPIClient) async {
|
||||||
isLoadingCollections = true
|
isLoadingCollections = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -961,6 +1005,10 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func displayableMessages(_ messages: [Message]) -> [Message] {
|
||||||
|
messages.filter { $0.role != .system }
|
||||||
|
}
|
||||||
|
|
||||||
private func chatTitle(title: String?, messages: [Message]?) -> String {
|
private func chatTitle(title: String?, messages: [Message]?) -> String {
|
||||||
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||||
return title
|
return title
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -26,8 +37,13 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilSearchResultsView(
|
SybilSearchResultsView(
|
||||||
search: viewModel.selectedSearch,
|
search: viewModel.selectedSearch,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isRunning: viewModel.isSending
|
isRunning: viewModel.isSending,
|
||||||
)
|
isStartingChat: viewModel.isCreatingSearchChat
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await viewModel.startChatFromSelectedSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SybilChatTranscriptView(
|
SybilChatTranscriptView(
|
||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
@@ -44,7 +60,23 @@ 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
|
.onChange(of: viewModel.isSending) { _, isSending in
|
||||||
@@ -56,6 +88,7 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
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 +96,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 +179,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 +202,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 +240,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 +254,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 +271,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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ function mapSearchResultPreview(result: any, index: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateContextPart(value: string | null | undefined, maxLength: number) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length <= maxLength) return trimmed;
|
||||||
|
return `${trimmed.slice(0, maxLength - 1).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAnswerText(answerResponse: any) {
|
function parseAnswerText(answerResponse: any) {
|
||||||
if (typeof answerResponse?.answer === "string") return answerResponse.answer;
|
if (typeof answerResponse?.answer === "string") return answerResponse.answer;
|
||||||
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
|
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
|
||||||
@@ -153,6 +160,57 @@ function normalizeUrlForMatch(input: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSearchChatContext(search: any) {
|
||||||
|
const query = truncateContextPart(search.query, 500) ?? truncateContextPart(search.title, 500) ?? "Untitled search";
|
||||||
|
const lines: string[] = [
|
||||||
|
"You are Sybil. The user started this chat from a saved web search. Use the search answer and result context below when answering follow-up questions. If the context is insufficient, say so and use available tools when appropriate.",
|
||||||
|
"",
|
||||||
|
`Search query: ${query}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const answer = truncateContextPart(search.answerText, 6000);
|
||||||
|
if (answer) {
|
||||||
|
lines.push("", "Search answer:", answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(search.answerCitations) && search.answerCitations.length) {
|
||||||
|
lines.push("", "Answer citations:");
|
||||||
|
for (const [index, citation] of search.answerCitations.slice(0, 8).entries()) {
|
||||||
|
const title = truncateContextPart(citation?.title, 160);
|
||||||
|
const url = truncateContextPart(citation?.url ?? citation?.id, 400);
|
||||||
|
if (title || url) {
|
||||||
|
lines.push(`${index + 1}. ${[title, url].filter(Boolean).join(" - ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(search.results) && search.results.length) {
|
||||||
|
lines.push("", "Search results:");
|
||||||
|
for (const result of search.results.slice(0, 10)) {
|
||||||
|
const title = truncateContextPart(result.title, 180) ?? result.url;
|
||||||
|
const url = truncateContextPart(result.url, 500);
|
||||||
|
const published = truncateContextPart(result.publishedDate, 80);
|
||||||
|
const author = truncateContextPart(result.author, 120);
|
||||||
|
const text = truncateContextPart(result.text, 1000);
|
||||||
|
const highlights = Array.isArray(result.highlights)
|
||||||
|
? result.highlights
|
||||||
|
.map((highlight: unknown) => truncateContextPart(typeof highlight === "string" ? highlight : null, 360))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
lines.push(`${result.rank + 1}. ${title}`);
|
||||||
|
if (url) lines.push(` URL: ${url}`);
|
||||||
|
if (published || author) lines.push(` Source detail: ${[published, author].filter(Boolean).join(" - ")}`);
|
||||||
|
if (text) lines.push(` Text: ${text}`);
|
||||||
|
for (const highlight of highlights.slice(0, 2)) {
|
||||||
|
lines.push(` Highlight: ${highlight}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildSseHeaders(originHeader: string | undefined) {
|
function buildSseHeaders(originHeader: string | undefined) {
|
||||||
const origin = originHeader && originHeader !== "null" ? originHeader : "*";
|
const origin = originHeader && originHeader !== "null" ? originHeader : "*";
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -370,6 +428,54 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { search };
|
return { search };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const Params = z.object({ searchId: z.string() });
|
||||||
|
const Body = z.object({ title: z.string().optional() });
|
||||||
|
const { searchId } = Params.parse(req.params);
|
||||||
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
|
const search = await prisma.search.findUnique({
|
||||||
|
where: { id: searchId },
|
||||||
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!search) return app.httpErrors.notFound("search not found");
|
||||||
|
|
||||||
|
const fallbackTitle = search.query?.trim() || search.title?.trim() || "Search results";
|
||||||
|
const title = body.title?.trim() || `Search: ${fallbackTitle.slice(0, 72)}`;
|
||||||
|
const context = buildSearchChatContext(search);
|
||||||
|
|
||||||
|
const chat = await prisma.chat.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
role: "system" as any,
|
||||||
|
content: context,
|
||||||
|
metadata: {
|
||||||
|
kind: "search_context",
|
||||||
|
searchId: search.id,
|
||||||
|
query: search.query,
|
||||||
|
resultCount: search.results.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chat };
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ searchId: z.string() });
|
const Params = z.object({ searchId: z.string() });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
|||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
import {
|
import {
|
||||||
createChat,
|
createChat,
|
||||||
|
createChatFromSearch,
|
||||||
createSearch,
|
createSearch,
|
||||||
deleteChat,
|
deleteChat,
|
||||||
deleteSearch,
|
deleteSearch,
|
||||||
@@ -164,6 +165,10 @@ function isToolCallLogMessage(message: Message) {
|
|||||||
return asToolLogMetadata(message.metadata) !== null;
|
return asToolLogMetadata(message.metadata) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDisplayableMessage(message: Message) {
|
||||||
|
return message.role !== "system";
|
||||||
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||||
return {
|
return {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
@@ -427,6 +432,7 @@ export default function App() {
|
|||||||
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
||||||
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
|
||||||
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
|
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
|
||||||
const [composer, setComposer] = useState("");
|
const [composer, setComposer] = useState("");
|
||||||
const [provider, setProvider] = useState<Provider>("openai");
|
const [provider, setProvider] = useState<Provider>("openai");
|
||||||
@@ -699,14 +705,14 @@ export default function App() {
|
|||||||
selectedItem?.kind === "chat" &&
|
selectedItem?.kind === "chat" &&
|
||||||
selectedItem.id === pendingChatState.chatId;
|
selectedItem.id === pendingChatState.chatId;
|
||||||
const displayMessages = useMemo(() => {
|
const displayMessages = useMemo(() => {
|
||||||
if (!pendingChatState) return messages;
|
if (!pendingChatState) return messages.filter(isDisplayableMessage);
|
||||||
if (pendingChatState.chatId) {
|
if (pendingChatState.chatId) {
|
||||||
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
|
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
|
||||||
return pendingChatState.messages;
|
return pendingChatState.messages.filter(isDisplayableMessage);
|
||||||
}
|
}
|
||||||
return messages;
|
return messages.filter(isDisplayableMessage);
|
||||||
}
|
}
|
||||||
return isSearchMode ? messages : pendingChatState.messages;
|
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
||||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
||||||
|
|
||||||
const selectedChatSummary = useMemo(() => {
|
const selectedChatSummary = useMemo(() => {
|
||||||
@@ -1149,6 +1155,47 @@ export default function App() {
|
|||||||
await refreshCollections({ kind: "search", id: searchId });
|
await refreshCollections({ kind: "search", id: searchId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartChatFromSearch = async () => {
|
||||||
|
if (!selectedSearch || isStartingSearchChat || isSending) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsStartingSearchChat(true);
|
||||||
|
try {
|
||||||
|
const chat = await createChatFromSearch(selectedSearch.id);
|
||||||
|
setDraftKind(null);
|
||||||
|
setPendingChatState(null);
|
||||||
|
setComposer("");
|
||||||
|
setChats((current) => {
|
||||||
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
|
return [chat, ...withoutExisting];
|
||||||
|
});
|
||||||
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
|
setSelectedChat({
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title,
|
||||||
|
createdAt: chat.createdAt,
|
||||||
|
updatedAt: chat.updatedAt,
|
||||||
|
initiatedProvider: chat.initiatedProvider,
|
||||||
|
initiatedModel: chat.initiatedModel,
|
||||||
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
|
lastUsedModel: chat.lastUsedModel,
|
||||||
|
messages: [],
|
||||||
|
});
|
||||||
|
setSelectedSearch(null);
|
||||||
|
await refreshCollections({ kind: "chat", id: chat.id });
|
||||||
|
await refreshChat(chat.id);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsStartingSearchChat(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const content = composer.trim();
|
const content = composer.trim();
|
||||||
if (!content || isSending) return;
|
if (!content || isSending) return;
|
||||||
@@ -1230,6 +1277,10 @@ export default function App() {
|
|||||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
SYBIL
|
SYBIL
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-3 px-3 pb-3">
|
<div className="space-y-3 px-3 pb-3">
|
||||||
@@ -1328,11 +1379,6 @@ export default function App() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||||
<p className="mt-0.5 flex items-center gap-1.5 text-xs 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"})` : ""}
|
|
||||||
{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">
|
||||||
@@ -1389,7 +1435,13 @@ export default function App() {
|
|||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
||||||
) : (
|
) : (
|
||||||
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
<SearchResultsPanel
|
||||||
|
search={selectedSearch}
|
||||||
|
isLoading={isLoadingSelection}
|
||||||
|
isRunning={isSearchRunning}
|
||||||
|
isStartingChat={isStartingSearchChat}
|
||||||
|
onStartChat={selectedSearch ? handleStartChatFromSearch : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
|||||||
import type { SearchDetail } from "@/lib/api";
|
import type { SearchDetail } from "@/lib/api";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MessageSquare } from "lucide-preact";
|
||||||
|
|
||||||
function formatHost(url: string) {
|
function formatHost(url: string) {
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +30,8 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
enableKeyboardNavigation?: boolean;
|
enableKeyboardNavigation?: boolean;
|
||||||
openLinksInNewTab?: boolean;
|
openLinksInNewTab?: boolean;
|
||||||
|
isStartingChat?: boolean;
|
||||||
|
onStartChat?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SearchResultsPanel({
|
export function SearchResultsPanel({
|
||||||
@@ -38,6 +41,8 @@ export function SearchResultsPanel({
|
|||||||
className,
|
className,
|
||||||
enableKeyboardNavigation = false,
|
enableKeyboardNavigation = false,
|
||||||
openLinksInNewTab = true,
|
openLinksInNewTab = true,
|
||||||
|
isStartingChat = false,
|
||||||
|
onStartChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
|
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
|
||||||
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
|
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
|
||||||
@@ -133,11 +138,13 @@ export function SearchResultsPanel({
|
|||||||
const isAnswerLoading = isRunning && !hasAnswerText;
|
const isAnswerLoading = isRunning && !hasAnswerText;
|
||||||
const hasCitations = citationEntries.length > 0;
|
const hasCitations = citationEntries.length > 0;
|
||||||
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
|
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
|
||||||
|
const canStartChat = !!search && !isLoading && !isRunning && !isStartingChat && (!!search.answerText || search.results.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "mx-auto w-full max-w-4xl"}>
|
<div className={className ?? "mx-auto w-full max-w-4xl"}>
|
||||||
{search?.query ? (
|
{search?.query ? (
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
<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 text-violet-50">{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">
|
||||||
@@ -145,6 +152,18 @@ export function SearchResultsPanel({
|
|||||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{onStartChat ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg border border-violet-300/24 bg-violet-300/10 px-3 text-sm font-medium text-violet-50 transition hover:bg-violet-300/16 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={onStartChat}
|
||||||
|
disabled={!canStartChat}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
{isStartingChat ? "Starting chat..." : "Chat with results"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
||||||
|
|||||||
@@ -239,6 +239,14 @@ export async function getSearch(searchId: string) {
|
|||||||
return data.search;
|
return data.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||||
|
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSearch(searchId: string) {
|
export async function deleteSearch(searchId: string) {
|
||||||
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user