Compare commits
11 Commits
4b0cc3fbf7
...
ios-pull-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9572d0320f | ||
| bca408c971 | |||
| 2f265fd847 | |||
| 29e340fd08 | |||
| 6fbcaecbf8 | |||
| 519ebd15dd | |||
| 8051dd2c71 | |||
| 2313e560e8 | |||
| 94565298d8 | |||
| 7360604136 | |||
| ca6b5e0807 |
@@ -45,9 +45,29 @@ Chat upload limits:
|
||||
- Response: `{ "chats": ChatSummary[] }`
|
||||
|
||||
### `POST /v1/chats`
|
||||
- Body: `{ "title"?: string }`
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai",
|
||||
"model": "optional model id",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system|user|assistant|tool",
|
||||
"content": "string",
|
||||
"name": "optional",
|
||||
"attachments": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` must be supplied together when present.
|
||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||
|
||||
### `PATCH /v1/chats/:chatId`
|
||||
- Body: `{ "title": string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
|
||||
@@ -19,6 +19,7 @@ Authentication:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"provider": "openai|anthropic|xai",
|
||||
"model": "string",
|
||||
"messages": [
|
||||
@@ -53,10 +54,12 @@ Authentication:
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `chatId` is omitted, backend creates a new chat.
|
||||
- `persist` defaults to `true`.
|
||||
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
|
||||
- If `chatId` is provided, backend validates it exists.
|
||||
- Backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages.
|
||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||
|
||||
## Event Stream Contract
|
||||
|
||||
@@ -71,13 +74,15 @@ Event order:
|
||||
```json
|
||||
{
|
||||
"type": "meta",
|
||||
"chatId": "chat-id",
|
||||
"callId": "llm-call-id",
|
||||
"chatId": "chat-id-or-null",
|
||||
"callId": "llm-call-id-or-null",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4.1-mini"
|
||||
}
|
||||
```
|
||||
|
||||
For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||
|
||||
### `delta`
|
||||
|
||||
```json
|
||||
@@ -141,24 +146,29 @@ Event order:
|
||||
Tool-enabled streaming notes (`openai`/`xai`):
|
||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||
- Stream may emit `tool_call` events while tool calls are executed.
|
||||
- `delta` events carry assistant text. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
- OpenAI Responses stream events are normalized by the backend into this SSE contract; clients do not consume OpenAI's raw Responses stream event names.
|
||||
|
||||
## Persistence + Consistency Model
|
||||
|
||||
Backend database remains source of truth.
|
||||
|
||||
During stream:
|
||||
For persisted streams:
|
||||
- Client may optimistically render accumulated `delta` text.
|
||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||
|
||||
On successful completion:
|
||||
On successful persisted completion:
|
||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||
- Backend then emits `done`.
|
||||
|
||||
On failure:
|
||||
On persisted failure:
|
||||
- Backend records call error and emits `error`.
|
||||
|
||||
For `persist: false` streams:
|
||||
- Client may render the same `meta`, `tool_call`, `delta`, and terminal events.
|
||||
- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows.
|
||||
- `done.text` is the canonical assistant text if the client later imports the result into a saved chat.
|
||||
|
||||
Client recommendation (for iOS/web):
|
||||
1. Render deltas in real time for UX.
|
||||
2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical.
|
||||
|
||||
@@ -9,5 +9,8 @@ struct SybilApp: App
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
SybilCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
targets:
|
||||
SybilApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
supportedDestinations:
|
||||
- iOS
|
||||
- macCatalyst
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- Sources
|
||||
@@ -12,15 +14,17 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
PRODUCT_NAME: Sybil
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
SWIFT_VERSION: 6.0
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.3
|
||||
CURRENT_PROJECT_VERSION: 4
|
||||
MARKETING_VERSION: 1.4
|
||||
CURRENT_PROJECT_VERSION: 5
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
@@ -5,6 +5,30 @@ public struct SplitView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SybilKeyboardActions(
|
||||
newChat: {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
},
|
||||
newSearch: {
|
||||
viewModel.startNewSearch()
|
||||
composerFocusRequest += 1
|
||||
},
|
||||
previousConversation: {
|
||||
viewModel.selectPreviousSidebarItem()
|
||||
},
|
||||
nextConversation: {
|
||||
viewModel.selectNextSidebarItem()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor public init() {
|
||||
SybilFontRegistry.registerIfNeeded()
|
||||
@@ -29,7 +53,10 @@ public struct SplitView: View {
|
||||
NavigationSplitView {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(SybilTheme.primary)
|
||||
@@ -37,6 +64,7 @@ public struct SplitView: View {
|
||||
}
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
.task {
|
||||
await viewModel.bootstrap()
|
||||
}
|
||||
@@ -63,3 +91,57 @@ public struct SplitView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
keyboardActions?.newChat()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
Button("Previous Conversation") {
|
||||
keyboardActions?.previousConversation()
|
||||
}
|
||||
.keyboardShortcut("[", modifiers: .command)
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
Button("Next Conversation") {
|
||||
keyboardActions?.nextConversation()
|
||||
}
|
||||
.keyboardShortcut("]", modifiers: .command)
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActions {
|
||||
var newChat: () -> Void
|
||||
var newSearch: () -> Void
|
||||
var previousConversation: () -> Void
|
||||
var nextConversation: () -> Void
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActionsKey: FocusedValueKey {
|
||||
typealias Value = SybilKeyboardActions
|
||||
}
|
||||
|
||||
private extension FocusedValues {
|
||||
var sybilKeyboardActions: SybilKeyboardActions? {
|
||||
get { self[SybilKeyboardActionsKey.self] }
|
||||
set { self[SybilKeyboardActionsKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
@State private var hasHandledInitialTranscriptScroll = false
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
@@ -51,6 +52,10 @@ struct SybilChatTranscriptView: View {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.refreshable {
|
||||
await onRefresh?()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
scrollToBottom(with: proxy, animated: false)
|
||||
|
||||
@@ -25,6 +25,7 @@ struct SybilPhoneShellView: View {
|
||||
@State private var path: [PhoneRoute] = []
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
@@ -37,7 +38,12 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
path: $path,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
route: route
|
||||
)
|
||||
}
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
@@ -126,6 +132,10 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshCollectionsFromUser()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
@@ -248,15 +258,25 @@ private struct SybilPhoneSidebarRow: View {
|
||||
|
||||
private struct SybilPhoneDestinationView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Binding var path: [PhoneRoute]
|
||||
@Binding var composerFocusRequest: Int
|
||||
let route: PhoneRoute
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
applyRoute()
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
path = [.draftChat]
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = .draftChat
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
applyRoute()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRoute() {
|
||||
|
||||
@@ -6,6 +6,7 @@ struct SybilSearchResultsView: View {
|
||||
var isLoading: Bool
|
||||
var isRunning: Bool
|
||||
var isStartingChat: Bool = false
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
var onStartChat: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -100,6 +101,10 @@ struct SybilSearchResultsView: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.refreshable {
|
||||
await onRefresh?()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -149,27 +149,12 @@ struct SybilSidebarView: View {
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshCollectionsFromUser()
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
|
||||
Button {
|
||||
viewModel.openSettings()
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.font(.sybil(.subheadline, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(10)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
.navigationTitle("")
|
||||
@@ -178,6 +163,17 @@ struct SybilSidebarView: View {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
viewModel.openSettings()
|
||||
} label: {
|
||||
Image(systemName: viewModel.selectedItem == .settings ? "gearshape.fill" : "gearshape")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(viewModel.selectedItem == .settings ? SybilTheme.primary : SybilTheme.textMuted)
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,62 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func selectPreviousSidebarItem() {
|
||||
selectAdjacentSidebarItem(offset: -1)
|
||||
}
|
||||
|
||||
func selectNextSidebarItem() {
|
||||
selectAdjacentSidebarItem(offset: 1)
|
||||
}
|
||||
|
||||
private func selectAdjacentSidebarItem(offset: Int) {
|
||||
let items = sidebarItems
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentIndex = selectedItem.flatMap { selection in
|
||||
items.firstIndex { $0.selection == selection }
|
||||
}
|
||||
let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1)
|
||||
let nextIndex = (startingIndex + offset + items.count) % items.count
|
||||
let nextSelection = items[nextIndex].selection
|
||||
|
||||
guard draftKind != nil || selectedItem != nextSelection else {
|
||||
return
|
||||
}
|
||||
|
||||
select(nextSelection)
|
||||
}
|
||||
|
||||
func refreshCollectionsFromUser() async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage = nil
|
||||
|
||||
guard draftKind == nil else {
|
||||
await refreshCollectionsPreservingDraft()
|
||||
return
|
||||
}
|
||||
|
||||
await refreshCollections(preferredSelection: selectedItem)
|
||||
}
|
||||
|
||||
func refreshSelectionFromUser() async {
|
||||
guard isAuthenticated, !isSending, !isCreatingSearchChat else {
|
||||
return
|
||||
}
|
||||
|
||||
guard selectedItem != nil, draftKind == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage = nil
|
||||
await refreshSelectionIfNeeded()
|
||||
}
|
||||
|
||||
func deleteItem(_ selection: SidebarSelection) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
@@ -682,6 +738,30 @@ final class SybilViewModel {
|
||||
settings.persist()
|
||||
}
|
||||
|
||||
private func refreshCollectionsPreservingDraft() async {
|
||||
isLoadingCollections = true
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
let (nextChats, nextSearches) = try await (chatsValue, searchesValue)
|
||||
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Refreshed collections for draft: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.app, "Refresh draft collections failed", error: error)
|
||||
}
|
||||
|
||||
isLoadingCollections = false
|
||||
}
|
||||
|
||||
private func refreshCollections(
|
||||
preferredSelection: SidebarSelection?,
|
||||
refreshSelection: Bool = true
|
||||
|
||||
@@ -6,12 +6,22 @@ import UIKit
|
||||
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var composerFocusRequest: Int = 0
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@State private var isShowingFileImporter = false
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
@State private var newChatSwipeOffset: CGFloat = 0
|
||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||
@State private var newChatSwipeIsActive = false
|
||||
@State private var newChatSwipeIsCompleting = false
|
||||
@State private var newChatSwipeHasLatched = false
|
||||
@State private var newChatSwipeDidTriggerHaptic = false
|
||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
@@ -34,7 +44,73 @@ struct SybilWorkspaceView: View {
|
||||
return "chat:none"
|
||||
}
|
||||
|
||||
private var canSwipeToCreateChat: Bool {
|
||||
guard onRequestNewChat != nil else {
|
||||
return false
|
||||
}
|
||||
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
||||
return false
|
||||
}
|
||||
guard case .chat = viewModel.selectedItem else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var canRecognizeNewChatSwipe: Bool {
|
||||
canSwipeToCreateChat && !newChatSwipeIsCompleting
|
||||
}
|
||||
|
||||
private var showsNewChatSwipeBackdrop: Bool {
|
||||
canSwipeToCreateChat || newChatSwipeIsCompleting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
if showsNewChatSwipeBackdrop {
|
||||
NewChatSwipeBackdrop(
|
||||
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
||||
hasLatched: newChatSwipeHasLatched
|
||||
)
|
||||
.padding(.trailing, 18)
|
||||
.padding(.vertical, 20)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
workspaceContent
|
||||
.compositingGroup()
|
||||
.offset(x: newChatSwipeOffset)
|
||||
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
|
||||
}
|
||||
.offset(x: newChatSwipeCompletionOffset)
|
||||
.background(SybilTheme.background)
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onChange(of: canSwipeToCreateChat) { _, isEnabled in
|
||||
guard !isEnabled else {
|
||||
return
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.task(id: composerFocusRequest) {
|
||||
await focusComposerIfRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private var workspaceContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showsHeader {
|
||||
header
|
||||
@@ -51,7 +127,10 @@ struct SybilWorkspaceView: View {
|
||||
search: viewModel.selectedSearch,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isRunning: viewModel.isSending,
|
||||
isStartingChat: viewModel.isCreatingSearchChat
|
||||
isStartingChat: viewModel.isCreatingSearchChat,
|
||||
onRefresh: {
|
||||
await viewModel.refreshSelectionFromUser()
|
||||
}
|
||||
) {
|
||||
Task {
|
||||
await viewModel.startChatFromSelectedSearch()
|
||||
@@ -61,12 +140,33 @@ struct SybilWorkspaceView: View {
|
||||
SybilChatTranscriptView(
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSending
|
||||
isSending: viewModel.isSending,
|
||||
onRefresh: {
|
||||
await viewModel.refreshSelectionFromUser()
|
||||
}
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
NewChatSwipePanInstaller(
|
||||
isEnabled: canRecognizeNewChatSwipe,
|
||||
onBegan: { width in
|
||||
beginNewChatSwipe(containerWidth: width)
|
||||
},
|
||||
onChanged: { translationX, width in
|
||||
updateNewChatSwipe(with: translationX, containerWidth: width)
|
||||
},
|
||||
onEnded: { translationX, width, didFinish in
|
||||
finishNewChatSwipe(
|
||||
translationX: translationX,
|
||||
containerWidth: width,
|
||||
didFinish: didFinish
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.showsComposer {
|
||||
Divider()
|
||||
@@ -74,22 +174,114 @@ struct SybilWorkspaceView: View {
|
||||
composerBar
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
newChatSwipeIsActive = true
|
||||
newChatSwipeHasLatched = false
|
||||
newChatSwipeDidTriggerHaptic = false
|
||||
}
|
||||
.background(SybilTheme.background)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction, update)
|
||||
|
||||
if newChatSwipeFeedbackGenerator == nil {
|
||||
newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid)
|
||||
}
|
||||
newChatSwipeFeedbackGenerator?.prepare()
|
||||
}
|
||||
|
||||
private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||
let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||
let wasLatched = newChatSwipeHasLatched
|
||||
let nextLatched = NewChatSwipeMetrics.isLatched(
|
||||
offset: nextOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: newChatSwipeHasLatched
|
||||
)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
newChatSwipeOffset = nextOffset
|
||||
newChatSwipeHasLatched = nextLatched
|
||||
}
|
||||
|
||||
if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic {
|
||||
newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95)
|
||||
newChatSwipeDidTriggerHaptic = true
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) {
|
||||
guard newChatSwipeIsActive else {
|
||||
resetNewChatSwipe(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
||||
|
||||
if didFinish && newChatSwipeHasLatched {
|
||||
Task {
|
||||
await completeNewChatSwipe(containerWidth: containerWidth)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
resetNewChatSwipe(animated: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeNewChatSwipe(containerWidth: CGFloat) async {
|
||||
newChatSwipeIsCompleting = true
|
||||
|
||||
withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) {
|
||||
newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot)
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
|
||||
onRequestNewChat?()
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func resetNewChatSwipe(animated: Bool) {
|
||||
let reset = {
|
||||
newChatSwipeOffset = 0
|
||||
newChatSwipeCompletionOffset = 0
|
||||
newChatSwipeIsActive = false
|
||||
newChatSwipeIsCompleting = false
|
||||
newChatSwipeHasLatched = false
|
||||
newChatSwipeDidTriggerHaptic = false
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
reset()
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
|
||||
newChatSwipeFeedbackGenerator = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func focusComposerIfRequested() async {
|
||||
guard composerFocusRequest > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
await Task.yield()
|
||||
try? await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
||||
return
|
||||
}
|
||||
composerFocused = true
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -286,11 +478,9 @@ struct SybilWorkspaceView: View {
|
||||
Button("Files") {
|
||||
isShowingFileImporter = true
|
||||
}
|
||||
if canPasteFromClipboard {
|
||||
Button("Paste from Clipboard") {
|
||||
Task {
|
||||
await pasteAttachmentsFromClipboard()
|
||||
}
|
||||
Button("Paste from Clipboard") {
|
||||
Task {
|
||||
await pasteAttachmentsFromClipboard()
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -343,20 +533,6 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var canPasteFromClipboard: Bool {
|
||||
let pasteboard = UIPasteboard.general
|
||||
if pasteboard.hasImages {
|
||||
return true
|
||||
}
|
||||
if let url = pasteboard.url, url.isFileURL {
|
||||
return true
|
||||
}
|
||||
if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||
do {
|
||||
@@ -401,6 +577,11 @@ struct SybilWorkspaceView: View {
|
||||
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
||||
}
|
||||
|
||||
guard !attachments.isEmpty else {
|
||||
viewModel.errorMessage = "Clipboard does not contain a supported attachment."
|
||||
return
|
||||
}
|
||||
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
composerFocused = true
|
||||
} catch {
|
||||
@@ -409,3 +590,356 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatSwipeMetrics {
|
||||
static let referenceWidth: CGFloat = 390
|
||||
static let horizontalActivationDistance: CGFloat = 18
|
||||
static let directionDominanceRatio: CGFloat = 1.22
|
||||
static let minimumLeftwardVelocity: CGFloat = 55
|
||||
static let latchHysteresis: CGFloat = 32
|
||||
static let completionOvershoot: CGFloat = 180
|
||||
static let completionAnimationDuration = 0.24
|
||||
static let completionAnimationDelayMs: UInt64 = 240
|
||||
|
||||
static func maxTravel(for width: CGFloat) -> CGFloat {
|
||||
min(max(width * 0.46, 156), 240)
|
||||
}
|
||||
|
||||
static func latchDistance(for width: CGFloat) -> CGFloat {
|
||||
min(max(width * 0.28, 112), 152)
|
||||
}
|
||||
|
||||
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||
max(min(rawTranslation, 0), -maxTravel(for: width))
|
||||
}
|
||||
|
||||
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
let travel = maxTravel(for: width)
|
||||
guard travel > 0 else {
|
||||
return 0
|
||||
}
|
||||
return min(max(abs(offset) / travel, 0), 1)
|
||||
}
|
||||
|
||||
static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
progress(for: offset, width: width) * 10
|
||||
}
|
||||
|
||||
static func shouldBeginPan(
|
||||
leftwardTravel: CGFloat,
|
||||
verticalTravel: CGFloat,
|
||||
leftwardVelocity: CGFloat,
|
||||
verticalVelocity: CGFloat
|
||||
) -> Bool {
|
||||
guard leftwardTravel > 0 || leftwardVelocity > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftwardTravel >= horizontalActivationDistance,
|
||||
leftwardTravel >= verticalTravel * directionDominanceRatio {
|
||||
return true
|
||||
}
|
||||
|
||||
return leftwardVelocity >= minimumLeftwardVelocity &&
|
||||
leftwardVelocity >= verticalVelocity * directionDominanceRatio
|
||||
}
|
||||
|
||||
static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
|
||||
max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance)
|
||||
}
|
||||
|
||||
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||
let distance = abs(offset)
|
||||
if isCurrentlyLatched {
|
||||
return distance >= latchReleaseDistance(for: width)
|
||||
}
|
||||
return distance >= latchDistance(for: width)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
||||
var isEnabled: Bool
|
||||
var onBegan: (CGFloat) -> Void
|
||||
var onChanged: (CGFloat, CGFloat) -> Void
|
||||
var onEnded: (CGFloat, CGFloat, Bool) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> InstallerView {
|
||||
let view = InstallerView()
|
||||
view.isUserInteractionEnabled = false
|
||||
view.coordinator = context.coordinator
|
||||
context.coordinator.markerView = view
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: InstallerView, context: Context) {
|
||||
context.coordinator.update(
|
||||
isEnabled: isEnabled,
|
||||
onBegan: onBegan,
|
||||
onChanged: onChanged,
|
||||
onEnded: onEnded
|
||||
)
|
||||
context.coordinator.markerView = uiView
|
||||
context.coordinator.installIfPossible()
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
|
||||
coordinator.detach()
|
||||
}
|
||||
|
||||
final class InstallerView: UIView {
|
||||
weak var coordinator: Coordinator?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
coordinator?.markerView = self
|
||||
coordinator?.installIfPossible()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
coordinator?.configureScrollViewFailureRequirements()
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
||||
weak var markerView: UIView?
|
||||
private weak var installedWindow: UIWindow?
|
||||
private let panGesture = UIPanGestureRecognizer()
|
||||
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
||||
|
||||
private var isEnabled = false
|
||||
private var onBegan: (CGFloat) -> Void = { _ in }
|
||||
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
||||
private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in }
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
panGesture.addTarget(self, action: #selector(handlePan(_:)))
|
||||
panGesture.cancelsTouchesInView = true
|
||||
panGesture.delaysTouchesBegan = false
|
||||
panGesture.delaysTouchesEnded = false
|
||||
panGesture.delegate = self
|
||||
}
|
||||
|
||||
func update(
|
||||
isEnabled: Bool,
|
||||
onBegan: @escaping (CGFloat) -> Void,
|
||||
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
||||
onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void
|
||||
) {
|
||||
self.isEnabled = isEnabled
|
||||
self.onBegan = onBegan
|
||||
self.onChanged = onChanged
|
||||
self.onEnded = onEnded
|
||||
panGesture.isEnabled = isEnabled
|
||||
configureScrollViewFailureRequirements()
|
||||
}
|
||||
|
||||
func installIfPossible() {
|
||||
guard let window = markerView?.window else {
|
||||
detach()
|
||||
return
|
||||
}
|
||||
|
||||
guard installedWindow !== window else {
|
||||
configureScrollViewFailureRequirements()
|
||||
return
|
||||
}
|
||||
|
||||
installedWindow?.removeGestureRecognizer(panGesture)
|
||||
window.addGestureRecognizer(panGesture)
|
||||
installedWindow = window
|
||||
configureScrollViewFailureRequirements()
|
||||
}
|
||||
|
||||
func detach() {
|
||||
installedWindow?.removeGestureRecognizer(panGesture)
|
||||
installedWindow = nil
|
||||
preparedScrollRecognizers = []
|
||||
}
|
||||
|
||||
func configureScrollViewFailureRequirements() {
|
||||
guard isEnabled, let markerView, let window = markerView.window else {
|
||||
return
|
||||
}
|
||||
|
||||
let markerFrame = markerView.convert(markerView.bounds, to: window)
|
||||
for scrollView in window.sybilDescendantScrollViews {
|
||||
let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer)
|
||||
guard !preparedScrollRecognizers.contains(recognizerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let scrollFrame = scrollView.convert(scrollView.bounds, to: window)
|
||||
if scrollFrame.intersects(markerFrame) {
|
||||
scrollView.panGestureRecognizer.require(toFail: panGesture)
|
||||
preparedScrollRecognizers.insert(recognizerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard let markerView else {
|
||||
return
|
||||
}
|
||||
|
||||
let width = max(markerView.bounds.width, 1)
|
||||
let translationX = recognizer.translation(in: markerView).x
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
onBegan(width)
|
||||
onChanged(translationX, width)
|
||||
|
||||
case .changed:
|
||||
onChanged(translationX, width)
|
||||
|
||||
case .ended:
|
||||
onEnded(translationX, width, true)
|
||||
|
||||
case .cancelled, .failed:
|
||||
onEnded(translationX, width, false)
|
||||
|
||||
case .possible:
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
onEnded(translationX, width, false)
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
||||
return false
|
||||
}
|
||||
|
||||
return markerView.bounds.contains(touch.location(in: markerView))
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
||||
return false
|
||||
}
|
||||
|
||||
let translation = panGesture.translation(in: markerView)
|
||||
let velocity = panGesture.velocity(in: markerView)
|
||||
return NewChatSwipeMetrics.shouldBeginPan(
|
||||
leftwardTravel: max(-translation.x, 0),
|
||||
verticalTravel: abs(translation.y),
|
||||
leftwardVelocity: max(-velocity.x, 0),
|
||||
verticalVelocity: abs(velocity.y)
|
||||
)
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
var sybilDescendantScrollViews: [UIScrollView] {
|
||||
var scrollViews: [UIScrollView] = []
|
||||
collectSybilScrollViews(into: &scrollViews)
|
||||
return scrollViews
|
||||
}
|
||||
|
||||
func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) {
|
||||
if let scrollView = self as? UIScrollView {
|
||||
scrollViews.append(scrollView)
|
||||
}
|
||||
|
||||
for subview in subviews {
|
||||
subview.collectSybilScrollViews(into: &scrollViews)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
private var clampedProgress: CGFloat {
|
||||
min(max(progress, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
Circle()
|
||||
.fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress)))
|
||||
.frame(width: 176, height: 176)
|
||||
.blur(radius: 44)
|
||||
.offset(x: 38, y: 18)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28),
|
||||
SybilTheme.surface.opacity(0.78)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 8,
|
||||
endRadius: 58
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(
|
||||
color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)),
|
||||
radius: 24,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20),
|
||||
Color.clear,
|
||||
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34)
|
||||
],
|
||||
center: .center
|
||||
)
|
||||
)
|
||||
.frame(width: 72, height: 72)
|
||||
.blur(radius: 10)
|
||||
|
||||
Image(systemName: hasLatched ? "checkmark" : "plus")
|
||||
.font(.system(size: 31, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.symbolEffect(.bounce, value: hasLatched)
|
||||
|
||||
Image(systemName: "sparkle")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
||||
.offset(x: -26, y: -25)
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface.opacity(0.42))
|
||||
.blur(radius: 16)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
.opacity(clampedProgress)
|
||||
.offset(x: (1 - clampedProgress) * 28)
|
||||
.animation(.easeOut(duration: 0.16), value: hasLatched)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
@@ -265,3 +266,21 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#expect(snapshot.getSearch == 1)
|
||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||
}
|
||||
|
||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||
let width: CGFloat = 390
|
||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||
let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)
|
||||
|
||||
#expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
|
||||
#expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
|
||||
#expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
|
||||
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
|
||||
#expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
|
||||
#expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
|
||||
#expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
|
||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
|
||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"prebuild": "node scripts/ensure-prisma-client.mjs",
|
||||
"dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --test --import tsx tests/**/*.test.ts",
|
||||
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
|
||||
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",
|
||||
|
||||
@@ -853,6 +853,12 @@ function extractResponsesText(response: any, fallback = "") {
|
||||
return parts.join("") || fallback;
|
||||
}
|
||||
|
||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
||||
if (!finalText) return "";
|
||||
if (!streamedText) return finalText;
|
||||
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
||||
}
|
||||
|
||||
function getResponseFailureMessage(response: any) {
|
||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||
@@ -1113,6 +1119,9 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let canStreamRoundText = false;
|
||||
let completedResponse: any | null = null;
|
||||
const completedOutputItems: any[] = [];
|
||||
|
||||
@@ -1121,8 +1130,23 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
|
||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
roundText += event.delta;
|
||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||
streamedRoundText += event.delta;
|
||||
yield { type: "delta", text: event.delta };
|
||||
}
|
||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||
canStreamRoundText = true;
|
||||
}
|
||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
}
|
||||
} else if (event?.type === "response.completed") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
@@ -1144,13 +1168,18 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractResponsesText(completedResponse, roundText);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(text)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
if (text) {
|
||||
yield { type: "delta", text };
|
||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
@@ -1214,6 +1243,8 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
@@ -1224,9 +1255,16 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
const deltaText = choice?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
roundText += deltaText;
|
||||
if (!roundHasToolCalls) {
|
||||
streamedRoundText += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||
if (deltaToolCalls.length) {
|
||||
roundHasToolCalls = true;
|
||||
}
|
||||
for (const toolCall of deltaToolCalls) {
|
||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||
@@ -1252,13 +1290,18 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(roundText)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
if (roundText) {
|
||||
yield { type: "delta", text: roundText };
|
||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
type StreamUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
export type StreamEvent =
|
||||
| { type: "meta"; chatId: string; callId: string; provider: Provider; model: string }
|
||||
| { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string }
|
||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||
| { type: "delta"; text: string }
|
||||
| { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }
|
||||
| { type: "done"; text: string; usage?: StreamUsage }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
function getChatIdOrCreate(chatId?: string) {
|
||||
@@ -24,39 +30,45 @@ function getChatIdOrCreate(chatId?: string) {
|
||||
|
||||
export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator<StreamEvent> {
|
||||
const t0 = performance.now();
|
||||
const chatId = await getChatIdOrCreate(req.chatId);
|
||||
const shouldPersist = req.persist !== false;
|
||||
const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null;
|
||||
|
||||
const call = await prisma.llmCall.create({
|
||||
data: {
|
||||
chatId,
|
||||
provider: req.provider as any,
|
||||
model: req.model,
|
||||
request: req as any,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const call =
|
||||
shouldPersist && chatId
|
||||
? await prisma.llmCall.create({
|
||||
data: {
|
||||
chatId,
|
||||
provider: req.provider as any,
|
||||
model: req.model,
|
||||
request: req as any,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: {
|
||||
lastUsedProvider: req.provider as any,
|
||||
lastUsedModel: req.model,
|
||||
},
|
||||
}),
|
||||
prisma.chat.updateMany({
|
||||
where: { id: chatId, initiatedProvider: null },
|
||||
data: {
|
||||
initiatedProvider: req.provider as any,
|
||||
initiatedModel: req.model,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (shouldPersist && chatId) {
|
||||
await prisma.$transaction([
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: {
|
||||
lastUsedProvider: req.provider as any,
|
||||
lastUsedModel: req.model,
|
||||
},
|
||||
}),
|
||||
prisma.chat.updateMany({
|
||||
where: { id: chatId, initiatedProvider: null },
|
||||
data: {
|
||||
initiatedProvider: req.provider as any,
|
||||
initiatedModel: req.model,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
|
||||
yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model };
|
||||
|
||||
let text = "";
|
||||
let usage: StreamEvent extends any ? any : never;
|
||||
let usage: StreamUsage | undefined;
|
||||
let raw: unknown = { streamed: true };
|
||||
|
||||
try {
|
||||
@@ -73,7 +85,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: runToolAwareChatCompletionsStream({
|
||||
@@ -85,7 +97,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
});
|
||||
for await (const ev of streamEvents) {
|
||||
@@ -96,16 +108,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
}
|
||||
|
||||
if (ev.type === "tool_call") {
|
||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
chatId: toolMessage.chatId,
|
||||
role: toolMessage.role as any,
|
||||
content: toolMessage.content,
|
||||
name: toolMessage.name,
|
||||
metadata: toolMessage.metadata as any,
|
||||
},
|
||||
});
|
||||
if (shouldPersist && chatId) {
|
||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
chatId: toolMessage.chatId,
|
||||
role: toolMessage.role as any,
|
||||
content: toolMessage.content,
|
||||
name: toolMessage.name,
|
||||
metadata: toolMessage.metadata as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
yield { type: "tool_call", event: ev.event };
|
||||
continue;
|
||||
}
|
||||
@@ -156,32 +170,36 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.message.create({
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
if (shouldPersist && chatId && call) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.message.create({
|
||||
data: { chatId, role: "assistant" as any, content: text },
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
response: raw as any,
|
||||
latencyMs,
|
||||
inputTokens: usage?.inputTokens,
|
||||
outputTokens: usage?.outputTokens,
|
||||
totalTokens: usage?.totalTokens,
|
||||
},
|
||||
});
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
response: raw as any,
|
||||
latencyMs,
|
||||
inputTokens: usage?.inputTokens,
|
||||
outputTokens: usage?.outputTokens,
|
||||
totalTokens: usage?.totalTokens,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
yield { type: "done", text, usage };
|
||||
} catch (e: any) {
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
await prisma.llmCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
error: e?.message ?? String(e),
|
||||
latencyMs,
|
||||
},
|
||||
});
|
||||
if (shouldPersist && call) {
|
||||
await prisma.llmCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
error: e?.message ?? String(e),
|
||||
latencyMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
yield { type: "error", message: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ChatMessage = {
|
||||
|
||||
export type MultiplexRequest = {
|
||||
chatId?: string;
|
||||
persist?: boolean;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
|
||||
@@ -327,10 +327,50 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Body = z.object({ title: z.string().optional() });
|
||||
const body = Body.parse(req.body ?? {});
|
||||
const Body = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]).optional(),
|
||||
model: z.string().trim().min(1).optional(),
|
||||
messages: z.array(CompletionMessageSchema).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.provider && !value.model) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "model is required when provider is supplied",
|
||||
path: ["model"],
|
||||
});
|
||||
}
|
||||
if (!value.provider && value.model) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "provider is required when model is supplied",
|
||||
path: ["provider"],
|
||||
});
|
||||
}
|
||||
});
|
||||
const parsed = Body.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = parsed.data;
|
||||
const chat = await prisma.chat.create({
|
||||
data: { title: body.title },
|
||||
data: {
|
||||
title: body.title,
|
||||
initiatedProvider: body.provider as any,
|
||||
initiatedModel: body.model,
|
||||
lastUsedProvider: body.provider as any,
|
||||
lastUsedModel: body.model,
|
||||
messages: body.messages?.length
|
||||
? {
|
||||
create: body.messages.map((message) => ({
|
||||
role: message.role as any,
|
||||
content: message.content,
|
||||
name: message.name,
|
||||
metadata: message.attachments?.length ? ({ attachments: message.attachments } as any) : undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -838,7 +878,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body);
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = parsed.data;
|
||||
|
||||
const msg = await prisma.message.create({
|
||||
data: {
|
||||
@@ -866,7 +908,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const body = Body.parse(req.body);
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = parsed.data;
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -891,16 +935,29 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
|
||||
const Body = z.object({
|
||||
chatId: z.string().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
const Body = z
|
||||
.object({
|
||||
chatId: z.string().optional(),
|
||||
persist: z.boolean().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.persist === false && value.chatId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "chatId must be omitted when persist is false",
|
||||
path: ["chatId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const body = Body.parse(req.body);
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = parsed.data;
|
||||
|
||||
// ensure chat exists if provided
|
||||
if (body.chatId) {
|
||||
@@ -909,11 +966,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.chatId) {
|
||||
if (body.persist !== false && body.chatId) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
const send = (event: string, data: any) => {
|
||||
reply.raw.write(`event: ${event}\n`);
|
||||
|
||||
107
server/tests/chat-tools-streaming.test.ts
Normal file
107
server/tests/chat-tools-streaming.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "../src/llm/chat-tools.js";
|
||||
|
||||
async function* streamFrom(events: any[]) {
|
||||
for (const event of events) {
|
||||
await Promise.resolve();
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
|
||||
const events: ToolAwareStreamingEvent[] = [];
|
||||
for await (const event of iterable) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
const outputMessage = {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
};
|
||||
const client = {
|
||||
responses: {
|
||||
create: async () =>
|
||||
streamFrom([
|
||||
{ type: "response.output_item.added", item: { ...outputMessage, content: [] }, output_index: 0 },
|
||||
{ type: "response.output_text.delta", delta: "Hel", output_index: 0, content_index: 0 },
|
||||
{ type: "response.output_text.delta", delta: "lo", output_index: 0, content_index: 0 },
|
||||
{ type: "response.output_item.done", item: outputMessage, output_index: 0 },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
output_text: "Hello",
|
||||
output: [outputMessage],
|
||||
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareOpenAIChatStream({
|
||||
client: client as any,
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["delta", "delta", "done"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.type === "delta").map((event) => event.text),
|
||||
["Hel", "lo"]
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||
});
|
||||
|
||||
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () =>
|
||||
streamFrom([
|
||||
{ choices: [{ delta: { role: "assistant" } }] },
|
||||
{ choices: [{ delta: { content: "Hel" } }] },
|
||||
{ choices: [{ delta: { content: "lo" } }] },
|
||||
{
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareChatCompletionsStream({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["delta", "delta", "done"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.type === "delta").map((event) => event.text),
|
||||
["Hel", "lo"]
|
||||
);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||
});
|
||||
@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
|
||||
- Composer adapts to the active item:
|
||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
||||
- Keyboard shortcuts:
|
||||
- `Cmd/Ctrl+J`: start a new chat.
|
||||
- `Shift+Cmd/Ctrl+J`: start a new search.
|
||||
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
|
||||
|
||||
Client API contract docs:
|
||||
- `../docs/api/rest.md`
|
||||
|
||||
534
web/src/App.tsx
534
web/src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
};
|
||||
|
||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||
|
||||
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||
|
||||
type QuickQuestionModelSelection = {
|
||||
provider: Provider;
|
||||
modelPreferences: ProviderModelPreferences;
|
||||
};
|
||||
|
||||
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
@@ -292,6 +298,37 @@ function loadStoredModelPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredProvider(value: unknown): Provider {
|
||||
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai";
|
||||
}
|
||||
|
||||
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
|
||||
const parsed = value as Partial<Record<Provider, unknown>>;
|
||||
return {
|
||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
|
||||
if (typeof window === "undefined") {
|
||||
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY);
|
||||
if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown };
|
||||
return {
|
||||
provider: normalizeStoredProvider(parsed.provider),
|
||||
modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences),
|
||||
};
|
||||
} catch {
|
||||
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
}
|
||||
}
|
||||
|
||||
function pickProviderModel(options: string[], preferred: string | null) {
|
||||
if (preferred?.trim()) return preferred.trim();
|
||||
return options[0] ?? "";
|
||||
@@ -620,6 +657,22 @@ export default function App() {
|
||||
const stored = loadStoredModelPreferences();
|
||||
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
|
||||
});
|
||||
const [quickProvider, setQuickProvider] = useState<Provider>(() => loadStoredQuickQuestionModelSelection().provider);
|
||||
const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState<ProviderModelPreferences>(
|
||||
() => loadStoredQuickQuestionModelSelection().modelPreferences
|
||||
);
|
||||
const [quickModel, setQuickModel] = useState(() => {
|
||||
const stored = loadStoredQuickQuestionModelSelection();
|
||||
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
|
||||
});
|
||||
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
||||
const [quickPrompt, setQuickPrompt] = useState("");
|
||||
const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState<string | null>(null);
|
||||
const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null);
|
||||
const [quickQuestionMessages, setQuickQuestionMessages] = useState<Message[]>([]);
|
||||
const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false);
|
||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -631,6 +684,7 @@ export default function App() {
|
||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||
const searchRunAbortRef = useRef<AbortController | null>(null);
|
||||
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
||||
const searchRunCounterRef = useRef(0);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const wasSendingRef = useRef(false);
|
||||
@@ -713,6 +767,12 @@ export default function App() {
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setQuickPrompt("");
|
||||
setQuickSubmittedPrompt(null);
|
||||
setQuickSubmittedModelSelection(null);
|
||||
setQuickQuestionMessages([]);
|
||||
setQuickQuestionError(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -846,6 +906,7 @@ export default function App() {
|
||||
}, [isAuthenticated, selectedItem]);
|
||||
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.trim()) return;
|
||||
@@ -859,6 +920,46 @@ export default function App() {
|
||||
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
|
||||
}, [providerModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (quickModel.trim()) return;
|
||||
setQuickModel((current) => {
|
||||
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||
});
|
||||
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
provider: quickProvider,
|
||||
modelPreferences: quickProviderModelPreferences,
|
||||
} satisfies QuickQuestionModelSelection)
|
||||
);
|
||||
}, [quickProvider, quickProviderModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen || typeof window === "undefined") return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) return;
|
||||
textarea.focus();
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
if (textarea.value.length > 0) {
|
||||
textarea.select();
|
||||
}
|
||||
});
|
||||
}, [isQuickQuestionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [quickPrompt, isQuickQuestionOpen]);
|
||||
|
||||
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||
const isChatReplyStreamingInView =
|
||||
isSending &&
|
||||
@@ -933,6 +1034,8 @@ export default function App() {
|
||||
return () => {
|
||||
searchRunAbortRef.current?.abort();
|
||||
searchRunAbortRef.current = null;
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
quickQuestionAbortRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -960,6 +1063,18 @@ export default function App() {
|
||||
}
|
||||
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
||||
const quickAnswerText = useMemo(() => {
|
||||
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = quickQuestionMessages[index];
|
||||
if (message.role === "assistant") return message.content;
|
||||
}
|
||||
return "";
|
||||
}, [quickQuestionMessages]);
|
||||
const canConvertQuickQuestion =
|
||||
Boolean(quickSubmittedPrompt?.trim()) &&
|
||||
Boolean(quickSubmittedModelSelection?.model.trim()) &&
|
||||
Boolean(quickAnswerText.trim()) &&
|
||||
!isQuickQuestionSending;
|
||||
|
||||
const selectedChatSummary = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "chat") return null;
|
||||
@@ -1008,6 +1123,10 @@ export default function App() {
|
||||
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
||||
return "Sybil";
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
||||
const primaryShortcutModifier = useMemo(() => {
|
||||
if (typeof navigator === "undefined") return "Ctrl";
|
||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pageTitle;
|
||||
@@ -1024,6 +1143,12 @@ export default function App() {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenQuickQuestion = () => {
|
||||
setQuickQuestionError(null);
|
||||
setIsQuickQuestionOpen(true);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateSearch = () => {
|
||||
setError(null);
|
||||
setContextMenu(null);
|
||||
@@ -1035,6 +1160,65 @@ export default function App() {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
|
||||
if (!filteredSidebarItems.length) return;
|
||||
|
||||
setError(null);
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setIsMobileSidebarOpen(false);
|
||||
setSelectedItem((current) => {
|
||||
const currentIndex = current
|
||||
? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
||||
: -1;
|
||||
const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1;
|
||||
const nextIndex =
|
||||
currentIndex < 0
|
||||
? fallbackIndex
|
||||
: Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||
const nextItem = filteredSidebarItems[nextIndex];
|
||||
return { kind: nextItem.kind, id: nextItem.id };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||
if (!hasPrimaryModifier || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "i" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
setQuickQuestionError(null);
|
||||
setIsQuickQuestionOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuickQuestionOpen) return;
|
||||
|
||||
if (key === "j") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
handleCreateSearch();
|
||||
} else {
|
||||
handleCreateChat();
|
||||
}
|
||||
focusComposer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectAdjacentSidebarItem(event.key === "ArrowUp" ? -1 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
||||
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
const menuWidth = 160;
|
||||
@@ -1084,6 +1268,17 @@ export default function App() {
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setIsQuickQuestionOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isQuickQuestionOpen]);
|
||||
|
||||
const handleOpenAttachmentPicker = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
@@ -1533,6 +1728,182 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendQuickQuestion = async () => {
|
||||
const content = quickPrompt.trim();
|
||||
if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return;
|
||||
|
||||
const selectedModel = quickModel.trim();
|
||||
if (!selectedModel) {
|
||||
setQuickQuestionError("No model available for selected provider");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const optimisticAssistantMessage: Message = {
|
||||
id: `temp-assistant-quick-${Date.now()}`,
|
||||
createdAt: now,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
quickQuestionAbortRef.current = abortController;
|
||||
|
||||
setQuickQuestionError(null);
|
||||
setQuickSubmittedPrompt(content);
|
||||
setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel });
|
||||
setQuickQuestionMessages([optimisticAssistantMessage]);
|
||||
setIsQuickQuestionSending(true);
|
||||
|
||||
let streamErrorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
await runCompletionStream(
|
||||
{
|
||||
persist: false,
|
||||
provider: quickProvider,
|
||||
model: selectedModel,
|
||||
messages: [{ role: "user", content }],
|
||||
},
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = current.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
|
||||
);
|
||||
if (assistantIndex < 0) return current.concat(toolMessage);
|
||||
return [
|
||||
...current.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...current.slice(assistantIndex),
|
||||
];
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: message.content + payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
streamErrorMessage = payload.message;
|
||||
},
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
if (streamErrorMessage) {
|
||||
throw new Error(streamErrorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setQuickQuestionError(message);
|
||||
}
|
||||
} finally {
|
||||
if (quickQuestionAbortRef.current === abortController) {
|
||||
quickQuestionAbortRef.current = null;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsQuickQuestionSending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertQuickQuestionToChat = async () => {
|
||||
const question = quickSubmittedPrompt?.trim();
|
||||
const answer = quickAnswerText.trim();
|
||||
const selection = quickSubmittedModelSelection;
|
||||
if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return;
|
||||
|
||||
setQuickQuestionError(null);
|
||||
setIsConvertingQuickQuestion(true);
|
||||
|
||||
try {
|
||||
const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question";
|
||||
const chat = await createChat({
|
||||
title,
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
messages: [
|
||||
{ role: "user", content: question },
|
||||
{ role: "assistant", content: answer },
|
||||
],
|
||||
});
|
||||
|
||||
setDraftKind(null);
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setProvider(selection.provider);
|
||||
setModel(selection.model);
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
await refreshCollections({ kind: "chat", id: chat.id });
|
||||
await refreshChat(chat.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setQuickQuestionError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConvertingQuickQuestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = composer.trim();
|
||||
const attachments = pendingAttachments;
|
||||
@@ -1629,13 +2000,36 @@ export default function App() {
|
||||
</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>
|
||||
<div className="flex gap-2">
|
||||
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
|
||||
{primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
className="h-11 w-11 rounded-lg"
|
||||
onClick={handleOpenQuickQuestion}
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
title={`${primaryShortcutModifier}+i`}
|
||||
aria-label="Quick question"
|
||||
>
|
||||
<Rabbit className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-violet-300/22 bg-[hsl(238_48%_7%)] px-2 py-1 text-xs font-semibold text-violet-100/90 opacity-0 shadow-xl shadow-black/35 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{primaryShortcutModifier}+i
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
New search
|
||||
<span className="ml-auto rounded-md border border-violet-100/10 bg-white/[0.035] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/44">
|
||||
Shift {primaryShortcutModifier} J
|
||||
</span>
|
||||
</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" />
|
||||
@@ -1901,6 +2295,136 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isQuickQuestionOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="quick-question-title"
|
||||
className="glass-panel flex max-h-[88vh] w-full max-w-3xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 id="quick-question-title" className="text-sm font-semibold text-violet-50">
|
||||
Quick question
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsQuickQuestionOpen(false)}
|
||||
aria-label="Close quick question"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3">
|
||||
<Textarea
|
||||
id="quick-question-input"
|
||||
rows={2}
|
||||
value={quickPrompt}
|
||||
onInput={(event) => {
|
||||
const textarea = event.currentTarget;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
const nextPrompt = textarea.value;
|
||||
if (nextPrompt !== quickPrompt) {
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
quickQuestionAbortRef.current = null;
|
||||
setIsQuickQuestionSending(false);
|
||||
setQuickSubmittedPrompt(null);
|
||||
setQuickSubmittedModelSelection(null);
|
||||
setQuickQuestionMessages([]);
|
||||
setQuickQuestionError(null);
|
||||
}
|
||||
setQuickPrompt(nextPrompt);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSendQuickQuestion();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Sybil..."
|
||||
className="max-h-36 min-h-[4.75rem] resize-none overflow-y-auto border-violet-300/24 bg-background/72 text-base text-violet-50 placeholder:text-violet-200/45"
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
/>
|
||||
|
||||
<div className="h-[min(34vh,22rem)] overflow-y-auto rounded-xl border border-violet-300/16 bg-background/38 px-3 py-4">
|
||||
{quickQuestionMessages.length ? (
|
||||
<ChatMessagesPanel messages={quickQuestionMessages} isLoading={false} isSending={isQuickQuestionSending} />
|
||||
) : null}
|
||||
{quickQuestionError ? (
|
||||
<p className="text-sm text-rose-300">{quickQuestionError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={quickProvider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setQuickProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
aria-label="Quick question provider"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
</select>
|
||||
<ModelCombobox
|
||||
options={quickProviderModelOptions}
|
||||
value={quickModel}
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
onChange={(nextModel) => {
|
||||
const normalizedModel = nextModel.trim();
|
||||
setQuickModel(normalizedModel);
|
||||
setQuickProviderModelPreferences((current) => ({
|
||||
...current,
|
||||
[quickProvider]: normalizedModel || null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleConvertQuickQuestionToChat()}
|
||||
disabled={!canConvertQuickQuestion || isConvertingQuickQuestion}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Convert to chat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-10 rounded-lg"
|
||||
onClick={() => void handleSendQuickQuestion()}
|
||||
size="icon"
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion || !quickPrompt.trim()}
|
||||
aria-label="Ask quick question"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ type Props = {
|
||||
|
||||
type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status?: "completed" | "failed";
|
||||
summary?: string;
|
||||
args?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
error?: string | null;
|
||||
resultPreview?: string | null;
|
||||
};
|
||||
|
||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
@@ -26,10 +33,26 @@ function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
|
||||
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
||||
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
||||
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
|
||||
return `Tool failed: ${metadata.error.trim()}`;
|
||||
}
|
||||
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
|
||||
if (message.content.trim()) return message.content.trim();
|
||||
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
||||
return `Ran tool '${toolName}'.`;
|
||||
}
|
||||
|
||||
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
|
||||
const raw = metadata.toolName?.trim() || message.name?.trim();
|
||||
if (!raw) return "Tool call";
|
||||
return raw
|
||||
.replace(/_/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getToolIconName(toolName: string | null | undefined) {
|
||||
const lowered = toolName?.toLowerCase() ?? "";
|
||||
if (lowered.includes("search")) return "search";
|
||||
@@ -37,6 +60,27 @@ function getToolIconName(toolName: string | null | undefined) {
|
||||
return "generic";
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: unknown) {
|
||||
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
|
||||
return `${Math.round(durationMs)} ms`;
|
||||
}
|
||||
|
||||
function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
||||
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
|
||||
if (!value) return null;
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
||||
return [
|
||||
isFailed ? "Failed" : "Completed",
|
||||
formatDuration(metadata.durationMs),
|
||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
|
||||
@@ -50,18 +94,39 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||
const isFailed = toolLogMetadata.status === "failed";
|
||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[85%] 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)]",
|
||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||
isFailed
|
||||
? "border-rose-500/40 bg-rose-950/18 text-rose-200"
|
||||
: "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
|
||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||
)}
|
||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{getToolSummary(message, toolLogMetadata)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
|
||||
{toolSummary}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
||||
{toolLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "preact/hooks";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { marked, Renderer } from "marked";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownMode = "default" | "citationTokens";
|
||||
@@ -21,8 +21,15 @@ function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitatio
|
||||
});
|
||||
}
|
||||
|
||||
const markdownRenderer = new Renderer();
|
||||
const renderTable = markdownRenderer.table.bind(markdownRenderer);
|
||||
|
||||
markdownRenderer.table = (token) => {
|
||||
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
|
||||
};
|
||||
|
||||
function renderMarkdown(markdown: string) {
|
||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true }) as string;
|
||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,77 @@ textarea {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.md-table-scroll {
|
||||
max-width: 100%;
|
||||
margin: 0.35rem 0 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid hsl(var(--border) / 0.86);
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(246 34% 10% / 0.76);
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||
}
|
||||
|
||||
.md-content table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.94em;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.md-table-scroll::-webkit-scrollbar {
|
||||
height: 0.45rem;
|
||||
}
|
||||
|
||||
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background: hsl(263 78% 72% / 0.34);
|
||||
}
|
||||
|
||||
.md-content th,
|
||||
.md-content td {
|
||||
padding: 0.48rem 0.7rem;
|
||||
border-right: 1px solid hsl(var(--border) / 0.72);
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.7);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.md-content th:last-child,
|
||||
.md-content td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.md-content tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.md-content th {
|
||||
background: hsl(251 40% 15% / 0.92);
|
||||
color: hsl(258 36% 98%);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.md-content td {
|
||||
color: hsl(258 34% 94% / 0.96);
|
||||
}
|
||||
|
||||
.md-content tbody tr:nth-child(odd) td {
|
||||
background: hsl(242 32% 10% / 0.58);
|
||||
}
|
||||
|
||||
.md-content tbody tr:nth-child(even) td {
|
||||
background: hsl(252 36% 13% / 0.46);
|
||||
}
|
||||
|
||||
.md-content tbody tr:hover td {
|
||||
background: hsl(263 46% 20% / 0.48);
|
||||
}
|
||||
|
||||
.md-content p + p {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -148,13 +148,20 @@ type CompletionResponse = {
|
||||
};
|
||||
|
||||
type CompletionStreamHandlers = {
|
||||
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
|
||||
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
|
||||
onToolCall?: (payload: ToolCallEvent) => void;
|
||||
onDelta?: (payload: { text: string }) => void;
|
||||
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||
onError?: (payload: { message: string }) => void;
|
||||
};
|
||||
|
||||
type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||
@@ -210,10 +217,11 @@ export async function listModels() {
|
||||
return api<ModelCatalogResponse>("/v1/models");
|
||||
}
|
||||
|
||||
export async function createChat(title?: string) {
|
||||
export async function createChat(input?: string | CreateChatRequest) {
|
||||
const body = typeof input === "string" ? { title: input } : input ?? {};
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
@@ -443,7 +451,8 @@ export async function runCompletion(body: {
|
||||
|
||||
export async function runCompletionStream(
|
||||
body: {
|
||||
chatId: string;
|
||||
chatId?: string | null;
|
||||
persist?: boolean;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
|
||||
Reference in New Issue
Block a user