Compare commits
10 Commits
a31c053298
...
codex/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
| 188c460826 | |||
| 90278020f5 | |||
| cafe4bb9ae | |||
| ba6fc9c660 | |||
| fa429dcbb3 | |||
| 05989e9fae | |||
| 70b831557f | |||
| cb368a4005 | |||
| adb9e15b6c | |||
| dacab2f6ee |
3
Tiltfile
3
Tiltfile
@@ -1,5 +1,8 @@
|
||||
update_settings(max_parallel_updates=4)
|
||||
|
||||
load('ext://dotenv', 'dotenv')
|
||||
dotenv() # defaults to .env in the current directory
|
||||
|
||||
local_resource(
|
||||
"server",
|
||||
cmd="npm ci --no-audit --no-fund",
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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:
|
||||
- OpenAI: `gpt-4.1-mini`
|
||||
- Anthropic: `claude-3-5-sonnet-latest`
|
||||
|
||||
BIN
ios/Apps/Sybil/Resources/Fonts/Inter.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/Inter.ttf
Normal file
Binary file not shown.
BIN
ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf
Normal file
Binary file not shown.
@@ -11,9 +11,9 @@ targets:
|
||||
product: Sybil
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.sybil.app
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: ""
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
SWIFT_VERSION: 6.0
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
|
||||
@@ -4,7 +4,10 @@ public struct SplitView: View {
|
||||
@State private var viewModel = SybilViewModel()
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
public init() {}
|
||||
@MainActor public init() {
|
||||
SybilFontRegistry.registerIfNeeded()
|
||||
SybilTheme.applySystemAppearance()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
@@ -23,7 +26,6 @@ public struct SplitView: View {
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
.navigationTitle("Sybil")
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
}
|
||||
@@ -31,6 +33,8 @@ public struct SplitView: View {
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
}
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.task {
|
||||
await viewModel.bootstrap()
|
||||
}
|
||||
|
||||
@@ -15,16 +15,17 @@ struct SybilChatTranscriptView: View {
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 24) {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ struct SybilChatTranscriptView: View {
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text("Assistant is typing…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.id("typing-indicator")
|
||||
@@ -86,11 +87,14 @@ private struct MessageBubble: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
if let toolCallMetadata {
|
||||
ToolCallActivityChip(
|
||||
metadata: toolCallMetadata,
|
||||
fallbackContent: message.content
|
||||
fallbackContent: message.content,
|
||||
createdAt: message.createdAt
|
||||
)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -98,9 +102,9 @@ private struct MessageBubble: View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
@@ -108,18 +112,16 @@ private struct MessageBubble: View {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTextStyle {
|
||||
FontSize(15)
|
||||
}
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser ? 14 : 2)
|
||||
.padding(.vertical, isUser ? 11 : 2)
|
||||
.padding(.vertical, isUser ? 13 : 2)
|
||||
.background(
|
||||
Group {
|
||||
if isUser {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(SybilTheme.userBubble.opacity(0.86))
|
||||
.fill(SybilTheme.userBubbleGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||
@@ -132,6 +134,23 @@ private struct MessageBubble: View {
|
||||
)
|
||||
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
||||
}
|
||||
|
||||
trailingSpacer
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var leadingSpacer: some View {
|
||||
if isUser {
|
||||
Spacer(minLength: 44)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingSpacer: some View {
|
||||
if !isUser {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,17 +158,38 @@ private struct MessageBubble: View {
|
||||
private struct ToolCallActivityChip: View {
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
|
||||
private var summary: String {
|
||||
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
return text
|
||||
}
|
||||
if (metadata.status ?? "").lowercased() == "failed",
|
||||
let error = metadata.error?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!error.isEmpty {
|
||||
return "Tool failed: \(error)"
|
||||
}
|
||||
if let preview = metadata.resultPreview?.trimmingCharacters(in: .whitespacesAndNewlines), !preview.isEmpty {
|
||||
return preview
|
||||
}
|
||||
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return fallbackContent
|
||||
}
|
||||
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
|
||||
}
|
||||
|
||||
private var toolLabel: String {
|
||||
let raw = metadata.toolName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !raw.isEmpty else { return "Tool call" }
|
||||
return raw
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(separator: " ")
|
||||
.map { word in
|
||||
word.prefix(1).uppercased() + word.dropFirst()
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
let name = (metadata.toolName ?? "").lowercased()
|
||||
if name.contains("search") {
|
||||
@@ -165,26 +205,60 @@ private struct ToolCallActivityChip: View {
|
||||
(metadata.status ?? "").lowercased() == "failed"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary)
|
||||
Text(summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
private var detailLabel: String {
|
||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||
pieces.append("\(durationMs) ms")
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
pieces.append(createdAt.sybilShortTimeLabel)
|
||||
return pieces.joined(separator: " • ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(summary)
|
||||
.font(.sybil(.subheadline))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(toolLabel)
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detailLabel)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12))
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: 460, alignment: .leading)
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,32 @@ struct SybilConnectionView: View {
|
||||
@Bindable var settings = viewModel.settings
|
||||
|
||||
VStack(spacing: 20) {
|
||||
HStack {
|
||||
SybilWordmark(size: 34)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.accent)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(
|
||||
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) {
|
||||
Text("Connect to Sybil")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
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)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -32,25 +41,25 @@ struct SybilConnectionView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("API URL")
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.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)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.surface)
|
||||
.fill(SybilTheme.surface.opacity(0.78))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||
)
|
||||
|
||||
Text("Admin Token")
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||
@@ -59,11 +68,11 @@ struct SybilConnectionView: View {
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.surface)
|
||||
.fill(SybilTheme.surface.opacity(0.78))
|
||||
)
|
||||
.overlay(
|
||||
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 {
|
||||
Text(authError)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -103,10 +112,10 @@ struct SybilConnectionView: View {
|
||||
.frame(maxWidth: 520)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(SybilTheme.card)
|
||||
.fill(SybilTheme.panelGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
.stroke(SybilTheme.border.opacity(0.9), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
165
ios/Packages/Sybil/Sources/Sybil/SybilMarkdownTheme.swift
Normal file
165
ios/Packages/Sybil/Sources/Sybil/SybilMarkdownTheme.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
extension Theme {
|
||||
static let sybilReadable: Theme = {
|
||||
SybilFontRegistry.registerIfNeeded()
|
||||
|
||||
return Theme()
|
||||
.text {
|
||||
FontFamily(.custom("Inter"))
|
||||
FontSize(15)
|
||||
ForegroundColor(SybilTheme.text)
|
||||
}
|
||||
.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,
|
||||
toolName: object["toolName"]?.stringValue ?? name,
|
||||
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 status: String?
|
||||
public var summary: String?
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
}
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
@@ -136,6 +142,13 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
||||
return nil
|
||||
}
|
||||
|
||||
public var numberValue: Double? {
|
||||
if case let .number(value) = self {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var objectValue: [String: JSONValue]? {
|
||||
if case let .object(value) = self {
|
||||
return value
|
||||
@@ -412,6 +425,14 @@ extension DateFormatter {
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let sybilShortTime: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .autoupdatingCurrent
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
extension Date {
|
||||
@@ -421,4 +442,8 @@ extension Date {
|
||||
formatter.unitsStyle = .abbreviated
|
||||
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 {
|
||||
NavigationStack(path: $path) {
|
||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||
.navigationTitle("Sybil")
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
||||
}
|
||||
@@ -43,14 +48,9 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
buttonRow
|
||||
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -65,7 +65,7 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -73,10 +73,10 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.title3)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
@@ -104,48 +104,67 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomToolbar
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomToolbar: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
|
||||
NavigationLink(value: PhoneRoute.settings) {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack(spacing: 12) {
|
||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||
path = [.settings]
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||
viewModel.startNewSearch()
|
||||
path = [.draftSearch]
|
||||
}
|
||||
|
||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||
viewModel.startNewChat()
|
||||
path = [.draftChat]
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(10)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background(SybilTheme.panelGradient)
|
||||
}
|
||||
.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)
|
||||
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)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,24 +175,36 @@ private struct SybilPhoneSidebarRow: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.sybil(.subheadline, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.caption2)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
if let initiated = item.initiatedLabel {
|
||||
Spacer(minLength: 0)
|
||||
Text(initiated)
|
||||
.font(.caption2)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +214,7 @@ private struct SybilPhoneSidebarRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
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(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
|
||||
@@ -12,15 +12,15 @@ struct SybilSearchResultsView: View {
|
||||
if let query = search?.query, !query.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Results for")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(query)
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(resultCountLabel)
|
||||
.font(.caption)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,14 @@ struct SybilSearchResultsView: View {
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text(isRunning ? "Searching Exa…" : "Loading search…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
|
||||
Text("No results found.")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ struct SybilSearchResultsView: View {
|
||||
|
||||
if let error = search?.error, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,9 @@ struct SybilSearchResultsView: View {
|
||||
private var answerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Answer")
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.primary.opacity(0.92))
|
||||
|
||||
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -90,21 +90,19 @@ struct SybilSearchResultsView: View {
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text("Generating answer…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if let answer = search?.answerText, !answer.isEmpty {
|
||||
Markdown(answer)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.markdownTextStyle {
|
||||
FontSize(15)
|
||||
}
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
|
||||
if let answerError = search?.answerError, !answerError.isEmpty {
|
||||
Text(answerError)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
|
||||
@@ -115,23 +113,23 @@ struct SybilSearchResultsView: View {
|
||||
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
|
||||
HStack(spacing: 6) {
|
||||
Text("[\(index + 1)]")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
|
||||
.font(.caption)
|
||||
.font(.sybil(.caption))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(SybilTheme.surface)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(SybilTheme.primary.opacity(0.10))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -142,10 +140,10 @@ struct SybilSearchResultsView: View {
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(SybilTheme.searchCard)
|
||||
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -171,45 +169,49 @@ private struct SearchResultCard: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text(host)
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.primary.opacity(0.9))
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.accent.opacity(0.9))
|
||||
.lineLimit(1)
|
||||
|
||||
if let resolvedURL {
|
||||
Link(destination: resolvedURL) {
|
||||
Text(result.title.orEmpty.orFallback(result.url))
|
||||
.font(.headline)
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
.font(.sybil(.headline))
|
||||
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
||||
.lineSpacing(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text(result.title.orEmpty.orFallback(result.url))
|
||||
.font(.headline)
|
||||
.foregroundStyle(SybilTheme.primary)
|
||||
.font(.sybil(.headline))
|
||||
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
||||
.lineSpacing(2)
|
||||
}
|
||||
|
||||
if let date = result.publishedDate, !date.isEmpty {
|
||||
Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)"))
|
||||
.font(.caption)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
} else if let author = result.author, !author.isEmpty {
|
||||
Text(author)
|
||||
.font(.caption)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
Text(result.url)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.92))
|
||||
.lineSpacing(2)
|
||||
.lineLimit(3)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if let highlights = result.highlights, !highlights.isEmpty {
|
||||
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
|
||||
Text("• \(highlight)")
|
||||
.font(.caption)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineSpacing(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -220,10 +222,10 @@ private struct SearchResultCard: View {
|
||||
.padding(14)
|
||||
.background(
|
||||
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(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
.stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -24,7 +24,7 @@ final class SybilSettingsStore {
|
||||
self.defaults = defaults
|
||||
|
||||
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.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
|
||||
@@ -64,10 +64,19 @@ final class SybilSettingsStore {
|
||||
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else { return nil }
|
||||
|
||||
if raw.hasSuffix("/") {
|
||||
while raw.hasSuffix("/") {
|
||||
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: 6) {
|
||||
Text("Connection")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`")
|
||||
.font(.footnote)
|
||||
Text("Use the API server root. Example: `http://127.0.0.1:8787`")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API URL")
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.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)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
@@ -38,7 +38,7 @@ struct SybilSettingsView: View {
|
||||
)
|
||||
|
||||
Text("Admin Token")
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||
@@ -82,19 +82,19 @@ struct SybilSettingsView: View {
|
||||
|
||||
if let mode = viewModel.authMode {
|
||||
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)
|
||||
}
|
||||
|
||||
if let authError = viewModel.authError {
|
||||
Text(authError)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
|
||||
if let runtimeError = viewModel.errorMessage {
|
||||
Text(runtimeError)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,27 +17,29 @@ struct SybilSidebarView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
viewModel.startNewChat()
|
||||
} label: {
|
||||
Label("New chat", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(spacing: 10) {
|
||||
sidebarActionButton(
|
||||
title: "New chat",
|
||||
systemImage: "plus",
|
||||
isPrimary: true,
|
||||
isActive: viewModel.draftKind == .chat
|
||||
) {
|
||||
viewModel.startNewChat()
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.startNewSearch()
|
||||
} label: {
|
||||
Label("New search", systemImage: "magnifyingglass")
|
||||
.frame(maxWidth: .infinity)
|
||||
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(.top, 12)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Divider()
|
||||
@@ -45,7 +47,7 @@ struct SybilSidebarView: View {
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -60,7 +62,7 @@ struct SybilSidebarView: View {
|
||||
ProgressView()
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Loading conversations…")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -68,10 +70,10 @@ struct SybilSidebarView: View {
|
||||
} else if viewModel.sidebarItems.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "message.badge")
|
||||
.font(.title3)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text("Start a chat or run your first search.")
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
@@ -87,24 +89,36 @@ struct SybilSidebarView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.sybil(.subheadline, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.caption2)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
if let initiated = item.initiatedLabel {
|
||||
Spacer(minLength: 0)
|
||||
Text(initiated)
|
||||
.font(.caption2)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +128,7 @@ struct SybilSidebarView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
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(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
@@ -144,7 +158,7 @@ struct SybilSidebarView: View {
|
||||
viewModel.openSettings()
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.sybil(.subheadline, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
@@ -157,6 +171,39 @@ struct SybilSidebarView: View {
|
||||
.buttonStyle(.plain)
|
||||
.padding(10)
|
||||
}
|
||||
.background(SybilTheme.surfaceStrong.opacity(0.84))
|
||||
.background(SybilTheme.panelGradient)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,212 @@
|
||||
import CoreText
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
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 {
|
||||
static let background = Color(red: 0.07, green: 0.05, blue: 0.11)
|
||||
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19)
|
||||
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23)
|
||||
static let card = Color(red: 0.12, green: 0.08, blue: 0.17)
|
||||
static let border = Color(red: 0.30, green: 0.22, blue: 0.39)
|
||||
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94)
|
||||
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78)
|
||||
static let text = Color(red: 0.95, green: 0.91, blue: 0.98)
|
||||
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80)
|
||||
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22)
|
||||
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62)
|
||||
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42)
|
||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||
static let surfaceStrong = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||
static let card = Color(red: 0.06, green: 0.05, blue: 0.12)
|
||||
static let border = Color(red: 0.24, green: 0.20, blue: 0.38)
|
||||
static let primary = Color(red: 0.57, green: 0.38, blue: 0.96)
|
||||
static let primarySoft = Color(red: 0.43, green: 0.25, blue: 0.76)
|
||||
static let accent = Color(red: 0.31, green: 0.88, blue: 0.95)
|
||||
static let text = Color(red: 0.96, green: 0.94, blue: 1.0)
|
||||
static let textMuted = Color(red: 0.65, green: 0.62, blue: 0.76)
|
||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||
|
||||
@MainActor static func applySystemAppearance() {
|
||||
let navAppearance = UINavigationBarAppearance()
|
||||
navAppearance.configureWithOpaqueBackground()
|
||||
navAppearance.backgroundColor = UIColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1)
|
||||
navAppearance.shadowColor = UIColor(red: 0.24, green: 0.20, blue: 0.38, alpha: 0.9)
|
||||
navAppearance.titleTextAttributes = [
|
||||
.foregroundColor: UIColor(red: 0.96, green: 0.94, blue: 1.0, alpha: 1)
|
||||
]
|
||||
navAppearance.largeTitleTextAttributes = navAppearance.titleTextAttributes
|
||||
|
||||
UINavigationBar.appearance().prefersLargeTitles = false
|
||||
UINavigationBar.appearance().standardAppearance = navAppearance
|
||||
UINavigationBar.appearance().compactAppearance = navAppearance
|
||||
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
|
||||
UINavigationBar.appearance().compactScrollEdgeAppearance = navAppearance
|
||||
}
|
||||
|
||||
static var backgroundGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.27, green: 0.12, blue: 0.36),
|
||||
Color(red: 0.15, green: 0.09, blue: 0.23),
|
||||
Color(red: 0.07, green: 0.05, blue: 0.11)
|
||||
Color(red: 0.12, green: 0.08, blue: 0.28),
|
||||
Color(red: 0.04, green: 0.03, blue: 0.09),
|
||||
background
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
|
||||
static var brandGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 1.0, green: 0.55, blue: 0.97),
|
||||
Color(red: 0.60, green: 0.43, blue: 1.0),
|
||||
Color(red: 0.40, green: 0.87, blue: 1.0)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
static var panelGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.07, green: 0.06, blue: 0.15).opacity(0.94),
|
||||
Color(red: 0.03, green: 0.03, blue: 0.08).opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
|
||||
static var primaryGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.43, green: 0.27, blue: 0.92),
|
||||
Color(red: 0.45, green: 0.09, blue: 0.63)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
static var selectedRowGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
primary.opacity(0.56),
|
||||
primarySoft.opacity(0.48)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
static var userBubbleGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.38, green: 0.16, blue: 0.80),
|
||||
Color(red: 0.25, green: 0.08, blue: 0.55)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
static var composerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.04, green: 0.04, blue: 0.10).opacity(0.98),
|
||||
Color(red: 0.09, green: 0.06, blue: 0.16).opacity(0.94)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
static var toolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
|
||||
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
static var failedToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
danger.opacity(0.18),
|
||||
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilWordmark: View {
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
Text("SYBIL")
|
||||
.font(.custom("Orbitron", size: size))
|
||||
.fontWeight(.black)
|
||||
.tracking(0)
|
||||
.foregroundStyle(SybilTheme.brandGradient)
|
||||
.accessibilityLabel("Sybil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,15 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var providerModelOptions: [String] {
|
||||
let serverModels = modelCatalog[provider]?.models ?? []
|
||||
modelOptions(for: provider)
|
||||
}
|
||||
|
||||
func modelOptions(for candidate: Provider) -> [String] {
|
||||
let serverModels = modelCatalog[candidate]?.models ?? []
|
||||
if !serverModels.isEmpty {
|
||||
return serverModels
|
||||
}
|
||||
return fallbackModels[provider] ?? []
|
||||
return fallbackModels[candidate] ?? []
|
||||
}
|
||||
|
||||
var selectedTitle: String {
|
||||
@@ -222,7 +226,7 @@ final class SybilViewModel {
|
||||
let initiatedLabel: String?
|
||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = chat.initiatedProvider {
|
||||
initiatedLabel = "\(provider.displayName) · \(model)"
|
||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||
} else {
|
||||
initiatedLabel = model
|
||||
}
|
||||
@@ -336,6 +340,15 @@ final class SybilViewModel {
|
||||
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() {
|
||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||
draftKind = .chat
|
||||
|
||||
@@ -12,12 +12,18 @@ struct SybilWorkspaceView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private var showsHeader: Bool {
|
||||
viewModel.errorMessage != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if showsHeader {
|
||||
header
|
||||
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
}
|
||||
|
||||
Group {
|
||||
if isSettingsSelected {
|
||||
@@ -45,6 +51,19 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(SybilTheme.background)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onChange(of: viewModel.isSending) { _, isSending in
|
||||
@@ -56,88 +75,76 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Spacer()
|
||||
|
||||
if !viewModel.isSearchMode && !isSettingsSelected {
|
||||
providerControls
|
||||
} else if viewModel.isSearchMode {
|
||||
Label("Search mode", systemImage: "globe")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(SybilTheme.surface)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||
}
|
||||
|
||||
private var providerControls: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Button(candidate.displayName) {
|
||||
viewModel.setProvider(candidate)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.provider.displayName, systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
private var providerModelMenu: some View {
|
||||
Menu {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.providerModelOptions, id: \.self) { model in
|
||||
Button(model) {
|
||||
viewModel.setModel(model)
|
||||
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: {
|
||||
Label(viewModel.model, systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
} 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 searchModeChip: some View {
|
||||
Label("Search", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(SybilTheme.accent.opacity(0.10))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var composerBar: some View {
|
||||
@@ -161,10 +168,10 @@ struct SybilWorkspaceView: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.surface)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.border, lineWidth: 1)
|
||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
@@ -175,11 +182,15 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.headline.weight(.semibold))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
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)
|
||||
}
|
||||
@@ -188,6 +199,15 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(SybilTheme.background)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
SybilTheme.background.opacity(0.18),
|
||||
SybilTheme.background.opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
200
web/src/App.tsx
200
web/src/App.tsx
@@ -248,7 +248,7 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<div className="flex h-9 min-w-56 items-center rounded-md border border-input bg-background px-2 text-sm">
|
||||
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
@@ -289,12 +289,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
|
||||
<div className="absolute right-0 z-50 mt-2 w-full rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{normalizedDraftValue && !hasExactOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||
onClick={commitDraftValue}
|
||||
>
|
||||
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
|
||||
@@ -306,7 +306,7 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
@@ -378,6 +378,32 @@ function formatDate(value: string) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getSidebarSectionLabel(value: string) {
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfItemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const dayDelta = Math.floor((startOfToday - startOfItemDay) / dayMs);
|
||||
|
||||
if (dayDelta <= 0) return "TODAY";
|
||||
if (dayDelta < 7) return "LAST 7 DAYS";
|
||||
return "EARLIER";
|
||||
}
|
||||
|
||||
function buildSidebarSections(items: SidebarItem[]) {
|
||||
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
const label = getSidebarSectionLabel(item.updatedAt);
|
||||
const section = sections.find((candidate) => candidate.label === label);
|
||||
if (section) {
|
||||
section.items.push(item);
|
||||
} else {
|
||||
sections.push({ label, items: [item] });
|
||||
}
|
||||
return sections;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
authTokenInput,
|
||||
@@ -422,6 +448,7 @@ export default function App() {
|
||||
const wasSendingRef = useRef(false);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
||||
const hasSyncedSelectionHistoryRef = useRef(false);
|
||||
|
||||
@@ -442,6 +469,17 @@ export default function App() {
|
||||
}, [composer]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
if (!query) return sidebarItems;
|
||||
return sidebarItems.filter((item) => {
|
||||
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
||||
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
});
|
||||
}, [sidebarItems, sidebarQuery]);
|
||||
const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]);
|
||||
|
||||
const resetWorkspaceState = () => {
|
||||
setChats([]);
|
||||
@@ -1171,12 +1209,12 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex h-full w-full overflow-hidden bg-background">
|
||||
<div className="app-grid-surface h-full p-0 md:p-2">
|
||||
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||
{isMobileSidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-30 bg-black/45 md:hidden"
|
||||
className="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm md:hidden"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
@@ -1184,22 +1222,41 @@ export default function App() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||
"glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
||||
<div className="px-4 pb-4 pt-5">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
</Button>
|
||||
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
New search
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||
<input
|
||||
value={sidebarQuery}
|
||||
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
||||
placeholder="Search chats"
|
||||
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<Separator className="bg-violet-300/10" />
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||
@@ -1207,45 +1264,60 @@ export default function App() {
|
||||
Start a chat or run your first search.
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarItems.map((item) => {
|
||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
||||
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
className={cn(
|
||||
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
||||
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
|
||||
)}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setSelectedItem({ kind: item.kind, id: item.id });
|
||||
setIsMobileSidebarOpen(false);
|
||||
}}
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs">
|
||||
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
|
||||
{initiatedLabel ? (
|
||||
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
|
||||
) : null}
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.label} className="mb-4">
|
||||
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
||||
{section.items.map((item) => {
|
||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
||||
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
className={cn(
|
||||
"mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
|
||||
active
|
||||
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
|
||||
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setSelectedItem({ kind: item.kind, id: item.id });
|
||||
setIsMobileSidebarOpen(false);
|
||||
}}
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
||||
)}
|
||||
>
|
||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<p className="truncate text-sm font-semibold">{item.title}</p>
|
||||
<p className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</p>
|
||||
</div>
|
||||
{initiatedLabel ? (
|
||||
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
|
||||
<div className="flex items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1259,18 +1331,14 @@ export default function App() {
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
{isSearchMode ? " • Exa Search" : ""}
|
||||
</p>
|
||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||
{!isSearchMode ? (
|
||||
<>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={provider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
@@ -1299,7 +1367,7 @@ export default function App() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
|
||||
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
||||
<Globe2 className="mr-2 h-4 w-4" />
|
||||
Search mode
|
||||
</div>
|
||||
@@ -1309,7 +1377,7 @@ export default function App() {
|
||||
|
||||
<div
|
||||
ref={transcriptContainerRef}
|
||||
className={cn("flex-1 overflow-y-auto px-3 pt-6 md:px-10", isSearchMode ? "pb-6" : "pb-28 md:pb-40")}
|
||||
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44"
|
||||
onScroll={() => {
|
||||
const container = transcriptContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -1325,8 +1393,8 @@ export default function App() {
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t p-3 md:p-4">
|
||||
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
|
||||
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
|
||||
<div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
|
||||
<Textarea
|
||||
id="composer-input"
|
||||
rows={1}
|
||||
@@ -1343,13 +1411,13 @@ export default function App() {
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
|
||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
||||
<Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1360,13 +1428,13 @@ export default function App() {
|
||||
{contextMenu ? (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
|
||||
className="fixed z-50 min-w-40 rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleDeleteFromContextMenu()}
|
||||
disabled={isSending}
|
||||
>
|
||||
|
||||
@@ -12,14 +12,20 @@ type Props = {
|
||||
|
||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#45215f_0%,#2a183d_45%,#191227_100%)] p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border bg-[hsl(276_32%_14%)] p-6 shadow-xl shadow-violet-950/45">
|
||||
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||
<div className="mb-6">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-violet-200 p-2 text-violet-900">
|
||||
<div className="rounded-lg border border-cyan-300/25 bg-cyan-400/12 p-2 text-cyan-200">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Sign in to Sybil</h1>
|
||||
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,6 +44,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
||||
value={authTokenInput}
|
||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||
disabled={isSigningIn}
|
||||
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
||||
/>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
return (
|
||||
<>
|
||||
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{messages.map((message) => {
|
||||
const toolLogMetadata = asToolLogMetadata(message.metadata);
|
||||
if (message.role === "tool" && toolLogMetadata) {
|
||||
@@ -53,13 +53,13 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[85%] items-center gap-2 rounded-md border px-3 py-2 text-xs leading-5",
|
||||
"inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-500/40 bg-rose-950/20 text-rose-200"
|
||||
: "border-cyan-500/35 bg-cyan-950/20 text-cyan-100"
|
||||
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
|
||||
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{getToolSummary(message, toolLogMetadata)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%]",
|
||||
isUser ? "rounded-2xl bg-violet-900/80 px-4 py-3 text-sm leading-6 text-fuchsia-50" : "text-base leading-7 text-fuchsia-100"
|
||||
isUser
|
||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
: "text-base leading-7 text-violet-50"
|
||||
)}
|
||||
>
|
||||
{isPendingAssistant ? (
|
||||
@@ -85,7 +87,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
) : (
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-fuchsia-100")}
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -94,7 +96,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
})}
|
||||
{isSending && !hasPendingAssistant ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] text-base leading-7 text-fuchsia-100">
|
||||
<div className="max-w-[85%] text-base leading-7 text-violet-50">
|
||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
|
||||
@@ -139,7 +139,7 @@ export function SearchResultsPanel({
|
||||
{search?.query ? (
|
||||
<div className="mb-5">
|
||||
<p className="text-sm text-muted-foreground">Results for</p>
|
||||
<h2 className="mt-1 break-words text-xl font-semibold">{search.query}</h2>
|
||||
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||
@@ -148,8 +148,8 @@ export function SearchResultsPanel({
|
||||
) : null}
|
||||
|
||||
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
||||
<section className="mb-6 rounded-xl border border-violet-400/35 bg-[hsl(276_31%_15%)] p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-violet-300/90">Answer</p>
|
||||
<section className="mb-6 rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(240_46%_8%_/_0.94),hsl(260_40%_12%_/_0.88))] p-4 shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||
<p className="text-xs font-semibold uppercase text-violet-300/90">Answer</p>
|
||||
{(isAnswerLoading || hasAnswerText) ? (
|
||||
<div className="mt-2">
|
||||
<div className="relative">
|
||||
@@ -172,7 +172,7 @@ export function SearchResultsPanel({
|
||||
)}
|
||||
</div>
|
||||
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(276_31%_15%)] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(252_42%_10%)] to-transparent" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 h-5">
|
||||
@@ -199,7 +199,7 @@ export function SearchResultsPanel({
|
||||
href={citation.href}
|
||||
target={openLinksInNewTab ? "_blank" : undefined}
|
||||
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
||||
className="max-w-full truncate rounded-md border border-violet-400/40 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
|
||||
className="max-w-full truncate rounded-md border border-violet-300/28 bg-violet-300/8 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
|
||||
>
|
||||
<span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span>
|
||||
{citation.label}
|
||||
@@ -225,23 +225,23 @@ export function SearchResultsPanel({
|
||||
<article
|
||||
key={result.id}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-[hsl(276_30%_13%)] px-4 py-4 shadow-sm transition-colors",
|
||||
index === activeResultIndex && "border-violet-300 ring-1 ring-violet-300/80"
|
||||
"rounded-lg border border-violet-300/16 bg-[linear-gradient(135deg,hsl(238_44%_7%_/_0.92),hsl(250_34%_10%_/_0.86))] px-4 py-4 shadow-sm transition-colors",
|
||||
index === activeResultIndex && "border-violet-300/55 ring-1 ring-violet-300/70"
|
||||
)}
|
||||
>
|
||||
<p className="truncate text-xs text-violet-300/85">{formatHost(result.url)}</p>
|
||||
<p className="truncate text-xs text-cyan-200/85">{formatHost(result.url)}</p>
|
||||
<a
|
||||
href={result.url}
|
||||
target={openLinksInNewTab ? "_blank" : undefined}
|
||||
rel={openLinksInNewTab ? "noreferrer" : undefined}
|
||||
className="mt-1 block break-words text-lg font-medium text-violet-300 hover:underline"
|
||||
className="mt-1 block break-words text-lg font-medium text-violet-200 hover:underline"
|
||||
>
|
||||
{result.title || result.url}
|
||||
</a>
|
||||
{(result.publishedDate || result.author) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
|
||||
)}
|
||||
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/90">{result.url}</p> : null}
|
||||
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/82">{result.url}</p> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,14 +3,16 @@ import type { JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
default:
|
||||
"border border-violet-300/35 bg-[linear-gradient(135deg,hsl(252_92%_64%_/_0.95),hsl(274_84%_35%_/_0.95))] text-primary-foreground shadow-sm hover:border-violet-200/55 hover:brightness-110",
|
||||
secondary:
|
||||
"border border-violet-300/18 bg-secondary/78 text-secondary-foreground shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] hover:border-violet-300/32 hover:bg-secondary",
|
||||
outline: "border border-input bg-background/76 hover:border-violet-300/45 hover:bg-accent/65 hover:text-accent-foreground",
|
||||
ghost: "text-muted-foreground hover:bg-accent/65 hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Input({ className, ...props }: JSX.InputHTMLAttributes<HTMLInput
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/78 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Textarea({ className, ...props }: JSX.TextareaHTMLAttributes<HTM
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background/78 px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Orbitron:wght@700;800;900&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 282 33% 8%;
|
||||
--foreground: 300 35% 95%;
|
||||
--muted: 287 24% 16%;
|
||||
--muted-foreground: 297 16% 72%;
|
||||
--border: 287 24% 24%;
|
||||
--input: 287 24% 24%;
|
||||
--ring: 264 76% 70%;
|
||||
--primary: 266 72% 67%;
|
||||
--primary-foreground: 296 45% 12%;
|
||||
--secondary: 277 24% 19%;
|
||||
--secondary-foreground: 302 42% 94%;
|
||||
--accent: 279 24% 22%;
|
||||
--accent-foreground: 305 45% 94%;
|
||||
--radius: 0.65rem;
|
||||
color-scheme: dark;
|
||||
--background: 235 45% 4%;
|
||||
--foreground: 258 36% 96%;
|
||||
--muted: 246 30% 13%;
|
||||
--muted-foreground: 247 18% 68%;
|
||||
--border: 251 35% 20%;
|
||||
--input: 252 42% 24%;
|
||||
--ring: 258 92% 70%;
|
||||
--primary: 264 93% 66%;
|
||||
--primary-foreground: 265 55% 98%;
|
||||
--secondary: 244 32% 14%;
|
||||
--secondary-foreground: 254 38% 94%;
|
||||
--accent: 188 86% 50%;
|
||||
--accent-foreground: 230 45% 8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
scrollbar-color: hsl(263 92% 68% / 0.42) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -31,8 +36,47 @@ body,
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
background-image: radial-gradient(circle at top, hsl(274 42% 18%) 0%, hsl(271 34% 12%) 42%, hsl(266 32% 7%) 100%);
|
||||
font-family: "Soehne", "Avenir Next", "Segoe UI", sans-serif;
|
||||
background-color: hsl(var(--background));
|
||||
background-image:
|
||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(264 92% 68% / 0.45);
|
||||
color: hsl(258 36% 98%);
|
||||
}
|
||||
|
||||
.sybil-wordmark {
|
||||
font-family: "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-grid-surface {
|
||||
background-image:
|
||||
linear-gradient(hsl(244 48% 20% / 0.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, hsl(244 48% 20% / 0.14) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||
hsl(236 48% 6%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(252 90% 86% / 0.08),
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
.md-content {
|
||||
|
||||
Reference in New Issue
Block a user